Compare commits

...
5 Commits
Author SHA1 Message Date
jason.woltje 364f1ae7a6 fix(tasks): U4a review fixes for the built-in task store (#1873)
Six review findings, one round:
- A selected task-tracker module that fails to load or has no factory refuses boot with tracker-config.
- The expect digest covers the task revision, so archive and unarchive invalidate an old token.
- Position exhaustion and an occupied explicit position refuse task-position-full; no rebalancing yet.
- The store adapter reads roles from the broker's current business document (Broker.taskRoles), so a role added by reload works.
- Only newly requested assignees are checked against live roles; stored ones survive edits, archive and delete.
- Human tasks.create requires a requirement citation (REQ-AREA-N); tasks.edit accepts one.
2026-11-09 19:55:25 -06:00
jason.woltje 9b7bf11afb feat(tasks): U4a, a task store built into each organization (#1873)
PRD 1.20 REQ-TASK-1, REQ-PLUG-1, REQ-WS-8. Each organization keeps its
tasks in its own bus through the new task-tracker plug-in kind. The
default module is the built-in store; Vikunja is the optional module
and a Vikunja organization keeps today's path.

- bus schema 3u to 3v (migration 3u-3v.sql): store_tasks, append-only,
  one row per write, each citing its events row; seven guard triggers.
- broker: the eight role verbs on the store with the slice 1 field
  table checked twice (module, then a before/after field diff); the
  person's tasks.create/edit/archive/unarchive/delete as human.input
  events; the tasks read returns store rows in board order (group,
  position), filtered and paged like tracker rows.
- runtime: each business gets its module at start (organization file
  plugin, else vikunja with a tracker entry, else store); a store
  business with a Vikunja tracker refuses the boot (tracker-config).
- plugins: task-tracker kind with store and vikunja modules.
- cli: the launcher's tracker flag covers store organizations; trail
  prints store writes.
- pins: schema.test counts, the bus migration tests, stack-release #811
  and T46 (store_tasks on the always list; COUNTED unchanged),
  budget-supervisor accepts 3v.

Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
2026-11-09 19:25:44 -06:00
jason.woltje feb98d5529 test(cli): compare file bytes in the org tester-init refusal check
Sol review of 221c1e9d3 (P2): the snapshot held only path names, so a
rewrite of .mosaic-org.json before the refusal passed. It now holds names,
types and file bytes; the reviewer's rewrite mutant fails it.
2026-11-09 19:17:28 -06:00
jason.woltje 221c1e9d35 Fix tester project tests for organization refusal 2026-11-09 19:05:09 -06:00
jason.woltje 544918a715 docs: record U1 and SYS1 landings (#1873) 2026-11-09 19:00:25 -06:00
42 changed files with 1467 additions and 100 deletions

No files matched your search

+2
View File
@@ -6345,3 +6345,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
2026-11-10 UTC, unified workspace U1 and SYS1 (#1873; PRs #1889 d35447005 and #1890 2c6556ab1). U1, before: each org page read only its own bus and nothing read every org at once. After: /api/workspace returns projects, sessions, inbox, tasks and metrics from every org the owner holds, each row tagged with its org and each org with a state (ok, starting, down, timeout, invalid); `mosaic console restart` and `mosaic org reload <org>` send the F3 routes. Sol review CHANGES 0/2 on 2109458b3: malformed data from one org turned the whole read into a 503, and the per-org deadline left sockets and transport children running. Terra fix 0672d5fe8 validates each org's result inside its boundary and carries an absolute deadline and AbortSignal through the status check and transport; the new workspace-failures test is 0/10 on the parent and 10/0 on the fix. The lead re-pinned the webui bus.mjs hash in bus channel-proof (the deliberate signal/deadline change; nonces, argv and limits unchanged) and amended as d35447005. SYS1, before: no system-type kind. After: a system-type plug-in kind with five modules (business-management, coding, creative-writing, marketing, montage), an org enable list under plugins.system-type, owner-only project selection with an append-only project_system_types table (migration 3t-3u) and CLI verbs; the modules grant no tools yet (REQ-SYS-2 deferred). Sol review CHANGES 1/2 on a9629f332: the restore guard did not count project_system_types, the CLI channel did not admit the two project verbs, and the #811 migration-reader pin still expected 18 steps. Lead fix 2c6556ab1 (rebased on d35447005) adds the table to COUNTED and LISTED with refusal and in-run recount tests, admits both verbs with a run()-level channel test (red on the unfixed channel), and moves the pin to 19 steps ending 3t-3u; fix files 323/0 and rebase overlap files 63/0 (Node 26.12.1, one file at a time, env -i). Full gate on a9629f332 4040/5: the two stack-release pins above, and tester-reads 3/3, which also fails on 1616d1dd6 (project.mjs:95 refuses an org --tester init; follow-up). Correction to the uiorg deploy at 00:50Z: the deploy script switched the tree without the offline schema migration, so all five buses refused to start (normal startup refuses an older schema by design). The lead ran migrate-schema.mjs for the five org stores (3t to 3u, all ready), restarted the buses (201 each) and added the migration to the deploy script with stop-on-refusal. test3 refused writer-locked and stays down, as before. Reports: /mnt/storage/scratch/mosaic-lead/reports/{u1-review,u1-fix,org-sys1-build,sys1-review}.md.
+1
View File
@@ -2123,3 +2123,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
2026-11-10 UTC | Mos (lead, Claude Opus 5.5) | U1 and SYS1 landings (#1873) | PR #1889 merged d35447005, PR #1890 merged 2c6556ab1; both Sol reviews CHANGES, fixed (U1 by Terra, SYS1 by the lead); uiorg at 2c6556ab1 after the missed 3t-3u store migration was run; deploy script now migrates.
+11
View File
@@ -1117,3 +1117,14 @@ For lifecycle tasks, the first review request includes the exact raw JSON
verification-document and verdict-body contract; unrelated reviews retain their
existing free-text contract. The typed record tools describe the existing closed
requirement schema without relaxing host validation.
## Built-in task store writes
The outside-agent human session can call `tasks.create` with `title` and a `requirement` ID
(such as `REQ-TASK-1`), plus optional task fields. `tasks.edit` accepts `task_ref` and the
fields to change, including `requirement`; archive, unarchive, and delete take `task_ref`.
Each write records `human.input`. A task read returns a `digest` that can be supplied as
`expect` to a later write; any intervening revision, including archive or unarchive, refuses
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
must be current roles. Automatic tail placement and an explicitly occupied position refuse
`task-position-full` when there is no room; no task row is written.
+41
View File
@@ -655,3 +655,44 @@ CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_t
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+379 -11
View File
@@ -95,7 +95,8 @@ export const REPLIES = 50;
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
export const STRANDED = 50;
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot'];
// U4a (#1873): `archived` reads the built-in store's archived tasks; a Vikunja task is never archived.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot', 'archived'];
export const TASK_PAGE = 50;
export const TASK_PAGE_MAX = 200;
// D3 (#797): raw UTF-8 pre-bound for the approved requirement texts quoted into one coder/reviewer
@@ -295,6 +296,105 @@ function identifier(x) {
return x;
}
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
// module. Its rows live in the organization's own bus.sqlite (store_tasks, migration 3u-3v). These
// shapes are exported for packages/tasks/src/store.mjs, the module, so the broker and the module
// share one definition; they stay in this file because its import list is the sandbox's closure.
// A task's fields, whole after every write:
// title, description text, as the Vikunja verbs take them
// due_date canonical ISO 8601 UTC with milliseconds, or null
// priority 0 (unset) to 5
// percent_done 0 to 1
// state todo, in-progress, in-review, blocked or done
// bucket, done what state implies, under the names the Console and `mosaic tasks` read
// labels positive label ids, sorted, at most 50
// assignees role instance names, sorted, at most 50
// group null or a short name: the column or list the task sits in
// position a whole number ordering tasks inside their group
// comments, comment how many comments, and the latest ({ text, by })
// verdict the review verdict a close cited
// request, op what a role's create cited; null on a person's
// requirement the requirement cited on create (or edited by a person)
export const STORE_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
// The states task.update.assigned moves between; done is task.close's (slice 1 field table).
export const ASSIGNED_STATES = Object.freeze(STORE_STATES.filter((x) => x !== 'done'));
// A built-in task's ref is its number in the organization, never reused.
const STORE_REF = /^task:([1-9][0-9]{0,14})$/;
export const storeRef = (x) => typeof x === 'string' && STORE_REF.test(x);
export const storeTaskNumber = (x) => (storeRef(x) ? Number(STORE_REF.exec(x)[1]) : null);
export const STORE_POSITION_MAX = 1_000_000_000;
// What a person's tasks.create and tasks.edit may set. Roles write through the eight task verbs,
// each held to the fields the field table gives it.
export const STORE_EDIT_FIELDS = Object.freeze(['title', 'description', 'due_date', 'priority', 'percent_done', 'state', 'labels', 'assignees', 'group', 'position', 'requirement']);
const isoMillis = (x) =>
typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x;
// One check per field: the raw value in, the stored value out, or a refusal.
export function storeFieldChecks(roles) {
const str = (x, max, min = 0) => {
if (typeof x !== 'string' || x.length < min || x.length > max || x.includes('\0')) fail('invalid-request');
return x;
};
const set = (x, item, order) => {
if (!Array.isArray(x) || x.length > 50) fail('invalid-request');
return [...new Set(x.map(item))].sort(order);
};
return {
title: (x) => (str(x, 250, 1).trim() ? x : fail('invalid-request')),
description: (x) => str(x, 65536),
due_date: (x) => (x === null || isoMillis(x) ? x : fail('invalid-request')),
priority: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : fail('invalid-request')),
percent_done: (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : fail('invalid-request')),
state: (x) => (STORE_STATES.includes(x) ? x : fail('invalid-state')),
labels: (x) => set(x, (l) => (Number.isSafeInteger(l) && l >= 1 ? l : fail('invalid-request')), (a, b) => a - b),
assignees: (x) => set(x, (r) => (typeof r === 'string' && Object.hasOwn(roles, r) ? r : fail('unknown-role'))),
group: (x) => (x === null ? null : str(x, 64, 1).trim() && !/[\u0000-\u001f\u007f]/.test(x) ? x : fail('invalid-request')),
position: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : fail('invalid-request')),
requirement: (x) => (typeof x === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(x) ? x : fail('invalid-request')),
};
}
// Checked edits on a copy of the fields. A state sets the bucket and done it implies. A move to
// another group with no position goes to the end of that group (a null position, which the write settles).
export function storeApply(fields, edits) {
const out = { ...fields, ...edits };
if (Object.hasOwn(edits, 'state')) Object.assign(out, { bucket: edits.state, done: edits.state === 'done' });
if (Object.hasOwn(edits, 'group') && !Object.hasOwn(edits, 'position') && edits.group !== fields.group) out.position = null;
return out;
}
// A new task's fields. A null position means after the last task in its group; the write settles it.
export function storeNewTask({ title, request = null, requirement = null, op = null }) {
return {
title, description: '', due_date: null, priority: 0, percent_done: 0, state: 'todo', bucket: 'todo', done: false,
labels: [], assignees: [], group: null, position: null, comments: 0, comment: null, verdict: null, request, requirement, op,
};
}
// Every field a stored task has, no more and no fewer.
export const STORE_FIELDS = Object.freeze(Object.keys(storeNewTask({ title: 'x' })));
// The fields each role verb may change: the slice 1 field table (packages/tasks/fields.json) on the
// store's fields. The table's labels and due date go with task.schedule, so the store's group and
// position do too; a comment's count goes with the latest comment; a close records its verdict; and
// a create sets what it cited. The broker refuses a role's write that changes any other field
// (field-writer), whatever the module asked for.
export const STORE_VERB_FIELDS = Object.freeze({
'task.create': Object.freeze(['title', 'description', 'due_date', 'priority', 'labels', 'group', 'position', 'request', 'requirement', 'op']),
'task.assign': Object.freeze(['assignees']),
'task.reassign': Object.freeze(['assignees']),
'task.schedule': Object.freeze(['due_date', 'labels', 'group', 'position']),
'task.priority.change': Object.freeze(['priority']),
'task.scope.change': Object.freeze(['title', 'description']),
'task.update.assigned': Object.freeze(['percent_done', 'state', 'bucket', 'comments', 'comment']),
'task.close': Object.freeze(['state', 'bucket', 'done', 'verdict']),
});
// Gaps between settled positions, so a person can place a task between two others.
export const STORE_POSITION_STEP = 1024;
// The most one task's fields take, as JSON.
const STORE_TASK_BYTES = 262144;
// Board order: ungrouped first, then group name, position and task number. Every read sorts this way.
export function storeOrder(a, b) {
const ga = a.group ?? '', gb = b.group ?? '';
if (ga !== gb) return ga < gb ? -1 : 1;
if ((a.position ?? 0) !== (b.position ?? 0)) return (a.position ?? 0) - (b.position ?? 0);
return a.task - b.task;
}
// A git commit id, SHA-1 or SHA-256.
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
@@ -544,8 +644,9 @@ export class Broker {
#accessRevoked = new Set();
#clock = 0;
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -555,6 +656,7 @@ export class Broker {
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
if (taskTracker !== null && typeof taskTracker !== 'function') throw new TypeError('taskTracker must be a function');
this.#businesses = structuredClone(businesses);
this.#store = store;
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
@@ -596,6 +698,11 @@ export class Broker {
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
this.#roleFile = roleFile;
// U4a (#1873): taskTracker(business) names the task-tracker module the bus runtime chose for the
// business at start. The store's writes refuse tracker-not-store under any other module, so a
// Vikunja organization never grows a second task list. Without it (the unit tests' brokers)
// every business uses the store.
this.#taskTracker = taskTracker ?? (() => 'store');
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
// These are host capabilities, like project/library; no filesystem module enters the client closure.
@@ -1104,6 +1211,12 @@ export class Broker {
this.#businesses[name] = next;
this.#scheduleRequests();
}
// Host-only current role resolver shared by the broker and built-in task tracker.
taskRoles(business) {
const roles = this.#business(business).roles;
if (!object(roles)) fail('invalid-business');
return structuredClone(roles);
}
// Lifecycle Task 2 (#797): the chat project a bootstrapped product lists under. Host-only, so no verb
// reaches it. State is 'free', 'associated' (this exact id, name and product) or 'conflict' (anything else
// holding the id or the name), which lets a retry insert the row once.
@@ -4632,11 +4745,16 @@ export class Broker {
// the short form in task_ref order: no description, and the assignees that are a role's tracker
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
// every task the filters match; `next` is the `after` for the next page, null on the last.
// U4a (#1873, REQ-WS-8): the store's tasks come after the Vikunja rows, in board order, in the same
// shape (task_ref, fields, digest, priority, requirement, at), with source 'store'. An archived store
// task leaves the no-argument read and the filtered read unless `archived: true` asks for archived
// tasks; `task_ref` alone still gives it, with archived true.
#tasks(business, a) {
const rows = this.#taskRows(business);
if (!Object.keys(a).length) return rows;
const rows = this.#taskRows(business), store = this.#storeRows(business);
if (!Object.keys(a).length) return [...rows, ...store.filter((r) => !r.archived)];
if ('task_ref' in a) {
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
if (Object.keys(a).length !== 1 || !(taskRef(a.task_ref) || storeRef(a.task_ref))) fail('invalid-request');
if (storeRef(a.task_ref)) return this.#storeCurrent(business, a.task_ref);
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
}
const roles = this.#business(business).roles ?? {};
@@ -4649,7 +4767,8 @@ export class Broker {
('project_key' in a && !projectKey(a.project_key)) ||
('requirement_snapshot' in a && !id(a.requirement_snapshot)) ||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
('after' in a && !taskRef(a.after))
('archived' in a && typeof a.archived !== 'boolean') ||
('after' in a && !(taskRef(a.after) || storeRef(a.after)))
)
fail('invalid-request');
const bots = new Map(
@@ -4680,10 +4799,209 @@ export class Broker {
(!('project_key' in a) || t.project_key === a.project_key) &&
(!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot),
);
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
// The store's short form adds its group, position and archived flag; its assignees are role names.
const archived = a.archived ?? false;
const filed = store
.filter((r) => r.archived === archived)
.map(({ task_ref, fields: f, priority, requirement, at, archived: gone }) => ({
task_ref,
title: f.title,
done: f.done,
bucket: f.bucket,
assignees: [...f.assignees],
labels: [...f.labels],
priority,
requirement,
changed_at: at,
group: f.group,
position: f.position,
archived: gone,
}))
.filter(
(t) =>
(!('done' in a) || t.done === a.done) &&
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
(!('label' in a) || t.labels.includes(a.label)) &&
(!('requirement' in a) || t.requirement === a.requirement) &&
!('project_key' in a) &&
!('requirement_snapshot' in a),
);
// Archived tasks are store tasks only. Pages run through the Vikunja tasks, then the store's: a
// store `after` skips every Vikunja task and the store tasks up to it in board order. A cursor whose
// task moved since continues from where it now stands; one whose task was deleted reads its last row.
const all = [...(archived ? [] : matched), ...filed];
let rest = all;
if (taskRef(a.after ?? null)) rest = all.filter((t) => !taskRef(t.task_ref) || t.task_ref > a.after);
else if ('after' in a) {
const n = storeTaskNumber(a.after);
const last = this.#store.get('SELECT fields FROM store_tasks WHERE business=? AND task=? ORDER BY seq DESC LIMIT 1', business, n);
if (!last) fail('task-not-found');
const f = JSON.parse(last.fields), at = { group: f.group, position: f.position, task: n };
rest = filed.filter((t) => storeOrder({ group: t.group, position: t.position, task: storeTaskNumber(t.task_ref) }, at) > 0);
}
const limit = a.limit ?? TASK_PAGE;
const tasks = rest.slice(0, limit);
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
return { tasks, total: all.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
// U4a (#1873, REQ-TASK-1): the built-in task store. A task is its latest store_tasks row, and it is
// archived when its latest archive or unarchive row is an archive. A deleted task's latest row is
// its delete, so it leaves every read here. Every read builds from the rows; nothing is cached.
#storeLatest(business, task = null) {
return this.#store
.all(
`SELECT t.*, (SELECT a.op FROM store_tasks a WHERE a.business=t.business AND a.task=t.task AND a.op IN ('archive','unarchive') ORDER BY a.seq DESC LIMIT 1) AS archive
FROM store_tasks t WHERE t.business=?${task === null ? '' : ' AND t.task=?'}
AND t.seq=(SELECT max(seq) FROM store_tasks WHERE business=t.business AND task=t.task) AND t.op<>'delete' ORDER BY t.task`,
...(task === null ? [business] : [business, task]),
)
.map((r) => {
const fields = JSON.parse(r.fields);
// The keys a Vikunja `tasks` row has that the Console and `mosaic tasks` read (task_ref,
// fields, digest, priority, requirement, at, seq, source), with source 'store', and the
// store's own: revision, the verb that wrote it, who (role and run, or the person), the event.
return {
seq: r.seq, at: r.at, business: r.business, task_ref: `task:${r.task}`, tracker: 'store', source: 'store',
updated: r.at, revision: r.revision, verb: r.verb, role: r.by_role, run: r.by_run, human: r.by_human, event: r.event,
digest: createHash('sha256').update(JSON.stringify([r.revision, r.fields])).digest('hex'), fields,
priority: fields.priority ?? null, requirement: fields.requirement ?? null, archived: r.archive === 'archive',
};
});
}
// The live tasks, archived ones too, in board order.
#storeRows(business) {
const at = (r) => ({ group: r.fields.group, position: r.fields.position, task: storeTaskNumber(r.task_ref) });
return this.#storeLatest(business).sort((a, b) => storeOrder(at(a), at(b)));
}
#storeOn(business) {
this.#business(business);
if (this.#taskTracker(business) !== 'store') fail('tracker-not-store');
}
#storeCurrent(business, ref) {
const n = storeTaskNumber(ref);
if (n === null) fail('invalid-request');
return this.#storeLatest(business, n)[0] ?? fail('task-not-found');
}
// A role's create cites the person's instruction that asked for it, as a Vikunja create does.
#storeRequest(business, request) {
return (
typeof request === 'string' &&
!!this.#store.get(
"SELECT 1 FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL AND json_extract(body,'$.kind')='instruction'",
business,
request,
)
);
}
// The whole task a write leaves: every field the store keeps and no other, each already in the form
// its check gives (labels and assignees sorted, no repeats), and the bucket and done its state implies.
#storeFields(business, f, previous) {
if (!object(f) || Object.keys(f).length !== STORE_FIELDS.length || !STORE_FIELDS.every((k) => Object.hasOwn(f, k))) fail('invalid-request');
const roles = { ...this.taskRoles(business), ...Object.fromEntries((previous?.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles);
for (const k of STORE_EDIT_FIELDS) {
if (k === 'requirement' && f[k] === null) continue; // historical tasks
if (JSON.stringify(check[k](f[k])) !== JSON.stringify(f[k])) fail('invalid-request');
}
const text = (x, max) => x === null || (typeof x === 'string' && x.length >= 1 && x.length <= max && !x.includes('\0'));
if (
f.bucket !== f.state ||
f.done !== (f.state === 'done') ||
!(Number.isSafeInteger(f.comments) && f.comments >= 0) ||
!(f.comment === null || (object(f.comment) && Object.keys(f.comment).length === 2 && text(f.comment.text, 10000) && f.comment.text !== null && Object.hasOwn(f.comment, 'by') && text(f.comment.by, 128) && f.comment.by !== null)) ||
!text(f.verdict, 256) ||
!text(f.request, 64) ||
!(f.requirement === null || (typeof f.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(f.requirement))) ||
!text(f.op, 256)
)
fail('invalid-request');
}
// Appends one row and the event that records it, and answers the task as it now reads (or that it
// was deleted). The caller owns the transaction and has checked who may write. A null position
// settles after the last task in the task's group.
#storeWrite(s, { current, op, verb, fields, by, kind, body }) {
const task = current
? storeTaskNumber(current.task_ref)
: (this.#store.get('SELECT max(task) AS n FROM store_tasks WHERE business=?', s.business).n ?? 0) + 1;
const ref = `task:${task}`;
const peers = this.#storeRows(s.business).filter((r) => r.task_ref !== ref && (r.fields.group ?? null) === (fields.group ?? null));
if (fields.position === null) {
const last = peers.reduce((m, r) => Math.max(m, r.fields.position ?? 0), -STORE_POSITION_STEP);
if (last + STORE_POSITION_STEP > STORE_POSITION_MAX) fail('task-position-full');
fields = { ...fields, position: last + STORE_POSITION_STEP };
} else if ((!current || fields.group !== current.fields.group || fields.position !== current.fields.position) && peers.some(r => r.fields.position === fields.position)) {
fail('task-position-full');
}
this.#storeFields(s.business, fields, current?.fields);
const json = JSON.stringify(fields);
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
this.#secretCheck(fields);
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
this.#store.run(
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
task,
current ? current.revision + 1 : 1,
op,
verb,
by.role,
by.run,
by.human,
event,
json,
);
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
}
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
// as #consumeAuthority spends it), a close's verdict check, the event and the row. `change` is the
// module's: it gets a copy of the task's fields (a new task's on a create) and of its row (null on a
// create) and returns the whole new fields. The write may change only what STORE_VERB_FIELDS gives
// its verb. An archived task takes no role write; a person unarchives it first. A create that names
// an `op` the same role already created with answers that task again, with nothing written; the same
// op with other fields, or from another role, refuses (task-create-conflict).
storeTask(cap, { verb, task_ref = null, decision = null, expect = null, verdict = null, change } = {}) {
const s = this.#session(cap);
if (!TASK_VERBS.includes(verb) || typeof change !== 'function' || (verb === 'task.create') !== (task_ref === null)) fail('invalid-request');
return this.#store.transaction(() => {
this.#agent(s);
this.#storeOn(s.business);
const current = task_ref === null ? null : this.#storeCurrent(s.business, task_ref);
if (current?.archived) fail('task-archived');
if (expect !== null && current && expect !== current.digest) fail('task-conflict');
const before = current?.fields ?? storeNewTask({ title: null }), allowed = STORE_VERB_FIELDS[verb];
const fields = change(structuredClone(before), current && structuredClone(current));
if (!object(fields)) fail('invalid-request');
for (const k of new Set([...Object.keys(before), ...Object.keys(fields)]))
if (JSON.stringify(before[k]) !== JSON.stringify(fields[k]) && !allowed.includes(k)) fail('field-writer');
if (!current && !this.#storeRequest(s.business, fields.request)) fail('request-not-found');
if (!current && typeof fields.op === 'string') {
const old = this.#store.get(
"SELECT task, by_role, fields FROM store_tasks WHERE business=? AND op='create' AND json_extract(fields,'$.op')=? ORDER BY seq LIMIT 1",
s.business,
fields.op,
);
if (old) {
const was = JSON.parse(old.fields), same = (k) => JSON.stringify(was[k]) === JSON.stringify(fields[k]);
if (old.by_role !== s.role || !STORE_FIELDS.every((k) => same(k) || (k === 'position' && fields.position === null))) fail('task-create-conflict');
this.#checkAuthority(s, verb, { decision, target: null });
const ref = `task:${old.task}`;
return this.#storeLatest(s.business, old.task)[0] ?? { task_ref: ref, deleted: true };
}
}
const authority = this.#checkAuthority(s, verb, { decision, target: task_ref });
if (authority.decision && this.#spent(s.business, authority.decision)) fail('decision-consumed');
if (verb === 'task.close') this.#closeVerdict(s, task_ref, verdict);
return this.#storeWrite(s, {
current,
op: current ? 'change' : 'create',
verb,
fields,
by: { role: s.role, run: s.run, human: null },
kind: 'action.allowed',
body: { action: verb, ...authority, target: task_ref, ...(verb === 'task.close' ? { verdict } : {}) },
});
});
}
#checkAuthority(s, action, context = {}) {
this.#processingWork();
@@ -5460,16 +5778,18 @@ export class Broker {
}
#refused(s, e, request, other = 'storage-refused') {
const error = e instanceof BusError ? e : new BusError(other);
// No request content or raw exception text in refusal evidence.
// No request content or raw exception text in refusal evidence. U4a: a built-in store task's
// ref files the refusal on that task's trail too.
const subject = (x) => taskRef(x) || storeRef(x);
try {
this.#store.transaction(() =>
this.#event(
s,
'action.refused',
{ code: error.code },
taskRef(request?.args?.task_ref)
subject(request?.args?.task_ref)
? request.args.task_ref
: taskRef(request?.args?.target)
: subject(request?.args?.target)
? request.args.target
: null,
),
@@ -7611,6 +7931,41 @@ export class Broker {
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
// nothing; a repeat of the state the project already holds refuses.
// U4a (#1873, REQ-TASK-1): the person's own writes to the built-in task store, from the terminal
// (human-cli). Each writes a human.input event (body kind 'task') that its store_tasks row cites, so a
// person's edit comes in as an event like any other input. The person writes any field, may reopen a
// done task, and is the only one who archives, unarchives or deletes. `expect` is the digest the
// person last read; a later write refuses task-conflict.
case 'tasks.create':
case 'tasks.edit':
case 'tasks.archive':
case 'tasks.unarchive':
case 'tasks.delete': {
this.#human(s);
this.#storeOn(s.business);
const creating = verb === 'tasks.create', editing = verb === 'tasks.edit';
keys(a, creating ? STORE_EDIT_FIELDS : editing ? ['task_ref', 'expect', ...STORE_EDIT_FIELDS] : ['task_ref', 'expect'], creating ? ['title', 'requirement'] : ['task_ref']);
if (Object.hasOwn(a, 'expect') && !(typeof a.expect === 'string' && /^[0-9a-f]{64}$/.test(a.expect))) fail('invalid-request');
const current = creating ? null : this.#storeCurrent(s.business, a.task_ref);
const roles = { ...this.taskRoles(s.business), ...Object.fromEntries((current?.fields.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles), edits = {};
for (const k of STORE_EDIT_FIELDS) if (Object.hasOwn(a, k)) edits[k] = check[k](a[k]);
if (editing && !Object.keys(edits).length) fail('invalid-request');
if (current && Object.hasOwn(a, 'expect') && a.expect !== current.digest) fail('task-conflict');
if (editing && current.archived) fail('task-archived');
if (verb === 'tasks.archive' && current.archived) fail('task-already-archived');
if (verb === 'tasks.unarchive' && !current.archived) fail('task-not-archived');
const fields = creating ? storeApply(storeNewTask({ title: edits.title }), edits) : editing ? storeApply(current.fields, edits) : current.fields;
return this.#storeWrite(s, {
current,
op: creating ? 'create' : editing ? 'change' : verb.slice('tasks.'.length),
verb,
fields,
by: { role: null, run: null, human: this.#owner(s) },
kind: 'human.input',
body: { kind: 'task' },
});
}
case 'project.archive':
case 'project.unarchive': {
this.#human(s);
@@ -8391,6 +8746,19 @@ export class Broker {
);
}
}
// U4a (#1873): a store task's rows (its whole history, a delete included) and the person's
// instruction a role's create cited, with that instruction's message.
if (storeRef(subject)) {
const own = this.#store.all('SELECT * FROM store_tasks WHERE business=? AND task=? ORDER BY seq', s.business, storeTaskNumber(subject));
add('store_tasks', own);
const request = own.length ? JSON.parse(own[0].fields).request : null;
const input = request && this.#store.get("SELECT * FROM events WHERE business=? AND kind='human.input' AND id=?", s.business, request);
if (input) {
add('events', [input]);
const body = JSON.parse(input.body);
if (body.message) add('messages', this.#store.all('SELECT * FROM messages WHERE business=? AND id=?', s.business, body.message));
}
}
for (const m of [...rows.values()])
if (m.table === 'messages')
add('deliveries', this.#store.all('SELECT * FROM deliveries WHERE message=?', m.id));
+41
View File
@@ -0,0 +1,41 @@
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+51 -4
View File
@@ -63,6 +63,8 @@ export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
// U4a (#1873): it serves the businesses whose task-tracker module is Vikunja; the others get theirs
// through the plug-in loader, the built-in store by default (see trackerOf in startBroker).
// REQ-HARN-2, REQ-PLUG-2 (decision 0b81b0c5): one web service per business. Its search source is
// the "search-source" plug-in the business's organization file switches on: searxng-url (the
// business var web.searxng) or searxng-bundled. With none, web.search refuses "not configured" and
@@ -653,7 +655,21 @@ export async function startBroker({
return no(declared ? 'project-pending' : 'project-unknown');
}
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1): the task-tracker module each business uses, fixed for this
// start. The organization file's "task-tracker" plug-in names it. With none, a business the boot
// config gives a Vikunja tracker keeps Vikunja (so does every business when an in-process caller
// hands `tasks` and no `trackers`), and any other business gets the built-in store. A business that
// names another module while the boot config also gives it a Vikunja tracker refuses the boot:
// two trackers can't both hold its tasks.
const trackerOf = new Map();
for (const [id, b] of Object.entries(businesses)) {
const named = b?.plugins?.['task-tracker'];
const tracked = trackers !== null && typeof trackers === 'object' && Object.hasOwn(trackers, id);
if (named !== undefined && named !== 'vikunja' && tracked) throw new BusError('tracker-config');
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
@@ -888,11 +904,42 @@ export async function startBroker({
}
const bound = launches.map(bindLaunch);
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
if (tasks) {
adapter = await tasks({ broker, credentials, businesses, processing });
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
// U4a: one adapter per task-tracker module in use, behind one handle that routes each call by the
// caller's business. The host's `tasks` factory is the Vikunja module. Any other module, and Vikunja
// when the host gave no factory but an organization file names it, loads through the plug-in loader
// with the boot config's `trackers`. A module that refuses to load or has no factory refuses
// startup with tracker-config. A module whose factory makes no
// adapter refuses the boot (invalid-adapter), and every adapter made so far is closed. The host's
// factory is always made when given, used or not, so its own checks of `trackers` still run.
const made = new Map();
adapter = { async close() { for (const a of made.values()) await a?.close?.(); } };
const pluginLog = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`);
for (const name of new Set([...(tasks ? ['vikunja'] : []), ...trackerOf.values()])) {
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
}
// A host may omit its implicit Vikunja adapter, retaining the legacy unconfigured path.
// An explicitly selected module must always supply a factory.
if (!factory && name === 'vikunja' && !tasks && !Object.values(businesses).some(b => b?.plugins?.['task-tracker'] === 'vikunja')) continue;
if (typeof factory !== 'function') throw new BusError('tracker-config');
const one = await factory({ broker, credentials, businesses, processing });
made.set(name, one);
if (typeof one?.handle !== 'function' || !Number.isSafeInteger(one.timeout) || one.timeout < 1)
throw new BusError('invalid-adapter');
}
if (made.size) {
adapter.timeout = Math.max(...[...made.values()].map((a) => a.timeout));
adapter.handle = async (cap, verb, args) => {
const one = made.get(trackerOf.get(broker.identity(cap).business));
if (!one) throw new BusError('tracker-unconfigured');
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses);
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
@@ -921,7 +968,7 @@ export async function startBroker({
web: (business) => web.get(business) ?? null,
host: host ? { busy: (...args) => host.busy(...args),
run: (...args) => processing.work(() => host.run(...args)) } : null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
tasks: adapter?.handle ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
peerReader,
+8 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '6f6f0c0fd471b7895648a71aa9f320a769b145da792c4152ee8282006eff30c7';
export const SCHEMA_VERSION = '3u';
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8';
export const SCHEMA_VERSION = '3v';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -115,6 +115,11 @@ const migrations = [{
}, {
from: '3t', to: '3u',
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
}, {
// U4a (#1873): the task store built into each organization, the default task-tracker module.
// Only a new append-only table, its index and its guards; no existing row changes.
from: '3u', to: '3v',
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -142,6 +147,7 @@ const timedTables = [
'request_project_events',
'project_archives',
'project_system_types',
'store_tasks',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -95,6 +95,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -102,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -152,7 +153,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -164,14 +165,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -616,7 +616,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3v';")
.replace('}];\nconst timedTables', `}, { from: '3u', to: '3v', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';")
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3u ready/);
assert.match(migrated.stdout, /bus schema 3v ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+7 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -85,6 +85,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -106,7 +107,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -132,7 +133,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -143,14 +144,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+7 -6
View File
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -88,6 +88,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -111,7 +112,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -136,7 +137,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -147,14 +148,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+3 -1
View File
@@ -199,6 +199,8 @@ test('3p through 3s migration preserves existing request rows and the append-onl
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
const db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z';
@@ -214,7 +216,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
+5 -4
View File
@@ -143,7 +143,8 @@ const here = (p) => new URL(p, import.meta.url);
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '');
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither.
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
function source3s(t) {
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
@@ -178,7 +179,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
@@ -211,7 +212,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3s';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';")
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -221,7 +222,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
assert.throws(() => new Store3s(root), /schema-mismatch/);
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
const { root: old } = source3s(t);
+6 -3
View File
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
});
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3u');
assert.equal(SCHEMA_VERSION, '3v');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
@@ -310,8 +310,11 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks.
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
@@ -328,7 +331,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
@@ -231,6 +231,8 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'advanced'", ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
@@ -251,7 +253,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
+6 -2
View File
@@ -279,6 +279,8 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
// predate it and the replayed chain recreates it, so the step's text comes out here too.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
@@ -291,7 +293,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n'); assert.equal(SCHEMA_VERSION, '3u');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
@@ -315,6 +317,8 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o);
@@ -331,7 +335,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+3 -3
View File
@@ -527,8 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
// 3t to 3u adds project_system_types and four guards.
assert.equal(count('table') - 1, 32);
assert.equal(count('trigger'), 123);
// 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards.
assert.equal(count('table') - 1, 33);
assert.equal(count('trigger'), 130);
assert.equal(count('view'), 5);
});
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
const after = tables(path);
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3m';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+4 -2
View File
@@ -67,7 +67,9 @@ test('3t store migrates offline without altering existing project rows', async t
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
assert.equal(ddl.split(step).length, 2);
db.exec(ddl.replace(step, ''));
// 3u-3v (U4a) adds store_tasks after this step; a 3t store has neither.
const later = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
db.exec(ddl.replace(step, '').replace(later, ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
@@ -77,7 +79,7 @@ test('3t store migrates offline without altering existing project rows', async t
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
});
test('runtime reads the current organization file for project selections and console discovery', async t => {
+548
View File
@@ -0,0 +1,548 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Client } from '../src/client.mjs';
import { storeAdapter } from '../../tasks/src/store.mjs';
import { loadFields } from '../../tasks/src/fields.mjs';
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the
// default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs
// over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every
// read is the bus's `tasks` verb, the same shape whichever module holds the tasks.
const AUTHORITY = {
pm: {
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send'],
crossRole: ['task.priority.change', 'task.scope.change'],
},
coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.scope.change'] },
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'] },
};
const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }]));
const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra });
const DIGEST = /^[0-9a-f]{64}$/;
// One bus (one data root) holding the given organizations, with the store module over it.
async function org(t, ids = ['acme'], { taskTracker = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ts-'));
const businesses = Object.fromEntries(ids.map((id) => [id, business(id)]));
const store = new Store(root);
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
const adapter = await storeAdapter()({ broker, businesses });
let removed = false;
t.after(() => {
adapter.close();
if (!removed) {
store.close();
rmSync(root, { recursive: true, force: true });
}
});
const caps = new Map(), humans = new Map();
const agent = (role, id = ids[0]) => {
const key = id + '/' + role;
if (!caps.has(key)) {
const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' });
broker.request(cap, { verb: 'role.claim' });
caps.set(key, cap);
}
return caps.get(key);
};
const human = (id = ids[0]) => {
if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }));
return humans.get(id);
};
const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle);
const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args });
const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args });
const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id);
// A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it.
const approve = (role, action, target, id = ids[0]) => {
const d = broker.request(agent(role, id), {
verb: 'decision.raise',
args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false },
});
broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
return d.id;
};
const remove = () => {
removed = true;
store.close();
rmSync(root, { recursive: true, force: true });
};
return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove };
}
const codeOf = async (p) => {
try {
await p;
} catch (e) {
return e.code;
}
return null;
};
const syncCode = (fn) => {
try {
fn();
} catch (e) {
return e.code;
}
return null;
};
test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => {
assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort());
for (const [verb, list] of Object.entries(STORE_VERB_FIELDS))
for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`);
});
test('REQ-TASK-1 every role verb writes the store through the module', async (t) => {
const f = await org(t);
const request = f.instruction();
const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.match(made.digest, DIGEST);
let row = f.read({ task_ref: 'task:1' });
assert.equal(row.source, 'store');
assert.equal(row.tracker, 'store');
assert.equal(row.role, 'pm');
assert.equal(row.verb, 'task.create');
assert.equal(row.revision, 1);
assert.equal(row.digest, made.digest);
assert.equal(row.archived, false);
assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 });
// The row's event is the action.allowed the write recorded, on the task's subject.
const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event);
assert.equal(event.kind, 'action.allowed');
assert.equal(event.subject, 'task:1');
assert.equal(JSON.parse(event.body).action, 'task.create');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned');
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' });
assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']);
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role');
await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z');
assert.deepEqual(row.fields.labels, [3, 5]);
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request');
await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.role, 'coder');
assert.equal(row.fields.state, 'in-progress');
assert.equal(row.fields.bucket, 'in-progress');
assert.equal(row.fields.percent_done, 0.5);
assert.equal(row.fields.comments, 1);
assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' });
// done is task.close's, never the assignee's.
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state');
// Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once.
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required');
const d = f.approve('pm', 'task.priority.change', 'task:1');
await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d });
assert.equal(f.read({ task_ref: 'task:1' }).priority, 4);
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed');
const scope = f.approve('coder', 'task.scope.change', 'task:1');
await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope });
assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well');
const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.digest, closed.digest);
assert.equal(row.fields.done, true);
assert.equal(row.fields.state, 'done');
assert.equal(row.fields.verdict, 'review-1');
// A done task takes no role write; reopening it is the person's.
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done');
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done');
// Nine writes; no refusal wrote a row.
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9);
assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => {
const f = await org(t);
await f.create('Owned fields');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// The module holds the pm verbs to the pm definition.
assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer');
assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer');
// task.update.assigned needs the role among the assignees.
assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned');
// The broker holds every write to its verb's fields, whatever the module asks for.
const pm = f.agent('pm'), coder = f.agent('coder');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer');
// A person's session is not a role here.
assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required');
// Vikunja-only arguments refuse; nothing was written by any refusal.
for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }])
assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra));
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2);
// A refusal on a store task is filed on its trail.
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned'));
});
test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => {
const f = await org(t);
const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] });
assert.equal(made.task_ref, 'task:1');
assert.equal(made.human, 'jason');
assert.equal(made.role, null);
assert.equal(made.fields.position, 0);
assert.equal(made.fields.request, null);
const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event);
assert.equal(event.kind, 'human.input');
assert.equal(event.subject, 'task:1');
assert.equal(event.actor_role, null);
assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' });
assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role');
// expect is the digest the person last read; a later write refuses.
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict');
const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] });
assert.equal(done.fields.done, true);
assert.equal(done.fields.bucket, 'done');
// The person may reopen a done task.
assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false);
// Agents and the reader don't reach the person's verbs.
assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required');
assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null);
const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' });
f.person('tasks.archive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived');
// An archived task leaves the board read and the filtered read, unless archived: true asks.
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]);
const archived = f.read({ archived: true });
assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]);
assert.equal(archived.total, 1);
assert.equal(f.read({ task_ref: 'task:1' }).archived, true);
f.person('tasks.unarchive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived');
assert.equal(f.read({ task_ref: 'task:1' }).archived, false);
const gone = f.person('tasks.delete', { task_ref: 'task:1' });
assert.equal(gone.task_ref, 'task:1');
assert.equal(gone.deleted, true);
assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found');
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found');
assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found');
// The history stays in the organization's bus until the organization is removed.
const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks');
assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']);
assert.equal(trail[0].fields.title, 'Plan');
// Numbers are never reused.
assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3');
});
test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => {
const f = await org(t);
const request = f.instruction();
const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' };
const first = await f.call('pm', 'task.create', args);
const again = await f.call('pm', 'task.create', args);
assert.deepEqual(again, first);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1);
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict');
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found');
assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request');
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input'));
assert.ok(trail.some((r) => r.table === 'messages'));
// A stale expect refuses a role write too.
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest });
});
test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => {
const f = await org(t);
for (const title of ['A', 'B', 'C']) await f.create(title);
await f.create('D', { group: 'review' });
await f.create('E', { group: 'build' });
const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]);
assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]);
// A move to another group with no position goes to its end; a position alone reorders in place.
await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' });
await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 });
f.person('tasks.edit', { task_ref: 'task:1', position: 5000 });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]);
// A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too.
f.person('tasks.edit', { task_ref: 'task:4', group: 'build' });
f.person('tasks.edit', { task_ref: 'task:5', group: null });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request');
// The filtered short form pages in board order with the store's group, position and archived.
const one = f.read({ limit: 2 });
assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']);
assert.equal(one.total, 5);
assert.equal(one.next, 'task:1');
assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']);
const two = f.read({ limit: 2, after: one.next });
assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']);
const three = f.read({ limit: 2, after: two.next });
assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']);
assert.equal(three.next, null);
assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found');
// The other filters apply to store tasks as to Vikunja's.
await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' });
assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']);
assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5);
assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []);
});
test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => {
// Jason 11-07: each organization has its own bus and data root.
const a = await org(t, ['acme']), b = await org(t, ['beta']);
await a.create('acme task');
await b.create('beta task');
b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' });
assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]);
assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]);
// Removing an organization removes its data root, and its tasks with it.
a.remove();
assert.equal(existsSync(a.root), false);
assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']);
await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// Within one bus, too, a business reads and writes only its own rows.
const both = await org(t, ['one', 'two']);
await both.create('one task', {}, 'one');
assert.deepEqual(both.read({}, 'two'), []);
assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found');
assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1');
assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']);
});
test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => {
const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' });
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store');
assert.equal(await codeOf(f.create('x')), 'tracker-not-store');
assert.deepEqual(f.read(), []);
assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError);
});
test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => {
const f = await org(t);
await f.create('Guarded');
const event = f.store.get('SELECT event FROM store_tasks').event;
const fields = f.store.get('SELECT fields FROM store_tasks').fields;
const insert = (task, revision, op, by = ['pm', 'pm-run', null]) =>
f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
'2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields);
for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'],
["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]])
assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql);
assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/);
assert.throws(() => insert(1, 2, 'create'), /a store task is created once/);
assert.throws(() => insert(3, 1, 'create'), /with the next number/);
assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/);
assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/);
assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/);
insert(1, 2, 'archive', [null, null, 'jason']);
assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/);
insert(1, 3, 'delete', [null, null, 'jason']);
assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3);
});
// --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. ---
const here = (p) => new URL(p, import.meta.url);
const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
} finally {
db.close();
}
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value;
} finally {
db.close();
}
};
test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => {
assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once');
assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql');
const root = mkdtempSync(join(tmpdir(), 'ts-3u-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
db.exec(currentDDL.replace(stepSQL, ''));
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run();
db.close();
chmodSync(path, 0o600);
const was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3v');
assert.equal(store.get('SELECT id FROM events').id, 'preserved');
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
});
// --- The bus runtime: the store is the default module; Vikunja stays the host's. ---
test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'ts-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const businesses = {
acme: business('acme', { plugins: { harness: ['pi'] } }),
beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }),
};
const seen = [];
let closed = 0;
const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' });
const runtime = await startBroker({
dataRoot: root,
businesses,
launches: [launch('acme'), launch('beta')],
trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } },
tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }),
});
try {
const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap }));
await acme.call('role.claim');
await beta.call('role.claim');
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' });
assert.deepEqual(seen, ['task.create']);
// The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way.
const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' });
assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]);
assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store');
} finally {
await runtime.close();
}
assert.equal(closed, 1);
// A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot.
await assert.rejects(
startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }),
(e) => e.code === 'tracker-config',
);
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
});
test('review: an unknown task-tracker module must refuse boot', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'unknown-'));
t.after(() => rmSync(root, {recursive:true, force:true}));
let runtime;
try {
await assert.rejects(async () => {
runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}});
}, (e) => e.code === 'tracker-config');
} finally { await runtime?.close(); }
// Startup cleanup releases the same store so a corrected configuration can start.
const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}});
await corrected.close();
});
test('review: a stale expect must reject a delete after an intervening archive', async (t) => {
const f=await org(t);
const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'});
const archived=f.person('tasks.archive', {task_ref:old.task_ref});
assert.equal(archived.revision, old.revision+1);
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict');
const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref});
assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict');
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict');
assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current');
});
test('review: human tasks can cite the PRD-required requirement id', async (t) => {
const f=await org(t);
const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'});
assert.equal(made.fields.requirement,'REQ-TASK-1');
assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request');
for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) {
assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request');
}
const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'});
assert.equal(edited.fields.requirement, 'REQ-TASK-2');
assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input');
});
test('review: a newly hired assignee can use the store after the business reloads', async (t) => {
const f=await org(t);
const made=await f.create('For a new coder');
const next=business('acme');
next.roles.newcoder=structuredClone(next.roles.coder);
f.broker.reloadBusiness('acme',next);
f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']});
await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'});
assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress');
});
test('review: deleting an assignee does not prevent archiving the existing task', async (t) => {
const f=await org(t);
const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']});
const next=business('acme'); delete next.roles.coder; next.retired=['coder'];
f.broker.reloadBusiness('acme',next);
const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']});
assert.deepEqual(edited.fields.assignees,['coder']);
assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role');
const archived=f.person('tasks.archive',{task_ref:made.task_ref});
assert.equal(archived.archived,true);
assert.deepEqual(archived.fields.assignees,['coder']);
f.person('tasks.delete',{task_ref:made.task_ref});
const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields);
assert.deepEqual(last.assignees,['coder']);
});
test('review: saturated and adjacent positions refuse without a write', async (t) => {
const f = await org(t);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000});
const before = f.store.get('SELECT count(*) n FROM store_tasks').n;
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11});
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
});
+5 -2
View File
@@ -301,12 +301,15 @@ test('the process loads the S3 adapter from plain-data trackers', async (t) => {
const bad = await boot(t, { dataRoot: root, businesses, launches, trackers: { nope: { baseUrl: 'http://127.0.0.1:9', project: 1 } } });
assert.deepEqual(bad.reply, { ok: false, error: 'tracker-config' });
await once(bad.child, 'exit');
// An empty tracker map loads the adapter; a business without an entry refuses task verbs.
// An empty tracker map loads the adapter. U4a (#1873): a business without an entry gets the
// built-in store, not Vikunja. These fixture roles carry no definition, so the store refuses the
// caller (tracker-role) where the Vikunja adapter refused tracker-unconfigured; task-store.test.mjs
// runs the store's verbs.
const { child, reply } = await boot(t, { dataRoot: root, businesses, launches, trackers: {} });
assert.equal(reply.ok, true);
const pm = new Client({ path: reply.path, cap: reply.launches[0].cap });
await pm.call('role.claim');
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-unconfigured/);
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-role/);
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3u ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3v ready', ''].join('\n'));
});
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3l';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3u');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+2 -1
View File
@@ -74,7 +74,8 @@ export function readAccounting({ dataRoot,business,start,end }) {
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
try {
db.exec('BEGIN');
if (!['3s','3t','3u'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
// U4a (#1873) 3u-3v adds only store_tasks, which accounting never reads, so 3v joins the closed list.
if (!['3s','3t','3u','3v'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
const records = turnRecords(store,business);
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
+6 -1
View File
@@ -648,7 +648,12 @@ async function bus(rest, io, deps) {
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
const launchEnv = instance ? fixtureLaunchEnv(orgDoc, io.env) : io.env;
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
// U4a (#1873): agents get the task tools when the bus serves task verbs for the organization: a
// Vikunja tracker in the boot config, or the built-in store, which an organization with no
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
await launcher.listen();
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
+3
View File
@@ -83,6 +83,9 @@ export function trailSummary(r) {
return `${r.op} ${r.role} by ${r.holder_run}`;
case "task_snapshots":
return `snapshot ${r.task_ref ?? ""}`.trim();
// U4a (#1873): a built-in store task's write: what it did, under which verb, by which role or person.
case "store_tasks":
return [r.op, r.verb, `r${r.revision}`, `by ${r.by_role ?? r.by_human}`, r.fields?.title ? one(r.fields.title, 100) : null].filter(Boolean).join(" ");
default:
return "";
}
+15
View File
@@ -168,6 +168,21 @@ test("agents and tasks print through the broker", async (t) => {
assert.equal(tasks.io.out.text, "tasks: none\n");
});
test("U4a: tasks and trail print the organization's built-in store tasks", async (t) => {
const s = setup(t);
s.b.request(s.human, { verb: "tasks.create", args: { title: "Plan the store", requirement: "REQ-TASK-1" } });
s.b.request(s.human, { verb: "tasks.edit", args: { task_ref: "task:1", state: "in-progress" } });
const tasks = await s.run(["tasks", "--business", "demo"]);
assert.equal(tasks.code, 0, tasks.io.err.text);
assert.equal(tasks.io.out.text, "task:1 in-progress Plan the store\n");
const trail = await s.run(["trail", "task:1", "--business", "demo"]);
assert.equal(trail.code, 0, trail.io.err.text);
const lines = trail.io.out.text.trimEnd().split("\n");
assert.equal(lines[0], `trail task:1: ${s.read("trail", { subject: "task:1" }).length} row(s)`);
assert.ok(lines.some((l) => / {2}store_tasks#1 {2}create {2}tasks\.create {2}r1 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
assert.ok(lines.some((l) => / {2}store_tasks#2 {2}change {2}tasks\.edit {2}r2 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
});
test("notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes", (t) => {
const dataRoot = tmp(t);
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3 && /no notifier config/.test(e.message));
+11
View File
@@ -37,3 +37,14 @@ test("the trail keeps the broker's order and names a decision's task without its
test("tasks print the tracker fields the snapshot carries", () => {
assert.equal(formatTasks([{ task_ref: "vikunja:1/7", fields: { title: "Build S4", done: false } }]), "vikunja:1/7 open Build S4\n");
});
test("U4a: store tasks print like tracker tasks, and their trail rows name the write", () => {
assert.equal(formatTasks([{ task_ref: "task:3", source: "store", fields: { title: "Plan", state: "in-progress", done: false } }]), "task:3 in-progress Plan\n");
const rows = [
{ at: "2026-11-09T10:00:00Z", table: "store_tasks", seq: 1, op: "create", verb: "task.create", revision: 1, by_role: "pm", by_human: null, fields: { title: "Plan" } },
{ at: "2026-11-09T11:00:00Z", table: "store_tasks", seq: 2, op: "archive", verb: "tasks.archive", revision: 2, by_role: null, by_human: "jason", fields: { title: "Plan" } },
];
const lines = formatTrail("task:3", rows).trimEnd().split("\n");
assert.equal(lines[1], "2026-11-09T10:00:00Z store_tasks#1 create task.create r1 by pm Plan");
assert.equal(lines[2], "2026-11-09T11:00:00Z store_tasks#2 archive tasks.archive r2 by jason Plan");
});
+5 -2
View File
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
assert.notEqual(engineSource,source);
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
const pairs=engine.migrations.map(({from,to})=>[from,to]);
assert.equal(pairs.length,19);assert.deepEqual(pairs.at(-1),['3t','3u']);
assert.equal(pairs.length,20);assert.deepEqual(pairs.at(-1),['3u','3v']);
const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8');
const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]};
const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n');
@@ -943,7 +943,10 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
const db=new DatabaseSync(':memory:');db.exec(readFileSync(join(ROOT,'packages','bus','schema.sql'),'utf8'));
const tables=db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r)=>r.name);
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
const always={
decisions:'decision.raise writes action.allowed in the same transaction',
store_tasks:'U4a #1873: each row goes in one transaction with the action.allowed or human.input events row it cites',
};
const untimed={
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
+45 -13
View File
@@ -5,7 +5,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
@@ -124,10 +124,10 @@ test("row 175: --tester on limits set, reset and migrate and every write form ou
// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it
// spawns can reach the runtime in this one.
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
function mosaic(args, env) {
function command(executable, args, env) {
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
const child = spawn(executable, args, { env: clean, timeout: 60000 });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
@@ -136,6 +136,8 @@ function mosaic(args, env) {
child.on("close", (status) => resolve({ status, out, err }));
});
}
const mosaic = (args, env) => command(MOSAIC, args, env);
// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1
// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli.
test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => {
@@ -157,36 +159,55 @@ test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and th
});
const listing = (dir) => readdirSync(dir, { recursive: true }).map(String).sort();
// Names, types and file bytes: a rewrite of an existing file shows up, not just a new path.
const contents = (dir) => listing(dir).map((name) => {
const path = join(dir, name), st = lstatSync(path);
return [name, st.isFile() ? readFileSync(path).toString("base64") : st.isDirectory() ? "dir" : `mode ${st.mode}`];
});
async function w7(t, vars, edit) {
const { root, f, org } = await orgFixture(t, "acme", edit);
const rt = await startBroker({ dataRoot: org.root, businesses: bus(vars) });
const root = tmp(t, "w7-");
const f = fixture(root, "acme", edit);
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(vars) });
t.after(() => rt.close());
return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } };
// Production CLI routes its transport through an organization. Inject the real tester transport
// in a child so the legacy non-org project path can still exercise the broker and its proof.
const driver = join(root, "driver.mjs");
writeFileSync(driver, `
import { run } from ${JSON.stringify(new URL("../src/cli.mjs", import.meta.url).href)};
import { testerTransport } from ${JSON.stringify(new URL("../src/transport.mjs", import.meta.url).href)};
const [socket, ...args] = process.argv.slice(2);
process.exitCode = await run(args, undefined, {
testerTransport: business => testerTransport({ socket, business }),
});
`);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const project = (args, env = agent) => command(process.execPath, [driver, join(f.dataRoot, "bus", "broker.sock"), ...args], env);
return { root, f, agent, project };
}
test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => {
const { f, agent } = await w7(t);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const { f, agent, project } = await w7(t);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 0, r.err);
const repo = join(f.dataRoot, "projects", "acme", "stack");
assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`));
const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" });
assert.equal(log.stdout, "tester|tester\n");
// Without --tester the same init inside an agent refuses, as before.
const again = await mosaic(["project", "init", "acme", "stack"], agent);
const again = await project(["project", "init", "acme", "stack"], agent);
assert.equal(again.status, 3);
assert.match(again.err, /outside any agent run/);
});
test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => {
// The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first.
const { f, agent } = await w7(t, null, (doc) => {
const { f, agent, project } = await w7(t, null, (doc) => {
doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records");
return doc;
});
const records = f.doc.projects.stack.repo;
const data = listing(f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 2, r.err);
assert.match(r.err, /tester-project-area/);
assert.equal(existsSync(records), false);
@@ -196,7 +217,7 @@ test("row 175 W7: a repo outside the project area refuses tester-project-area be
test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => {
const off = await w7(t, null);
const data = listing(off.f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent);
const r = await off.project(["project", "init", "acme", "stack", "--tester"], off.agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /tester-off/);
assert.deepEqual(listing(off.f.dataRoot), data);
@@ -204,10 +225,21 @@ test("row 175 W7: the tester bind refuses tester-off and tester-required before
const on = await w7(t);
const before = listing(on.f.dataRoot);
for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) {
const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
const under = await on.project(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
assert.equal(under.status, 3, `${marker}: ${under.err}`);
assert.match(under.err, /tester-required/, marker);
}
assert.deepEqual(listing(on.f.dataRoot), before);
assert.equal(existsSync(join(on.f.dataRoot, "projects")), false);
});
// Organization setup deliberately refuses tester init before creating a project record.
test("organization project init --tester refuses without writing", async (t) => {
const { f, org } = await orgFixture(t);
const before = contents(org.root);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /organization project init --tester is unavailable/);
assert.deepEqual(contents(org.root), before);
});
+9
View File
@@ -93,6 +93,15 @@ export const KINDS = Object.freeze({
theme: Object.freeze({
kind: "theme", interfaceVersion: 1, exports: Object.freeze(["theme"]), switch: "list",
}),
// Where an organization keeps its tasks (U4a, REQ-TASK-1). The organization file names one module;
// with none, the bus uses `store`, the task store built into each organization's own bus. `vikunja`
// is the optional Vikunja tracker, set up from the boot config's `trackers` as before. Core calls
// adapter({ trackers }) -> async ({ broker, credentials, businesses, processing }) -> { handle, timeout, close }
// once per bus, where handle(cap, verb, args) answers the eight task verbs for a role's cap and
// timeout is in milliseconds. Every module answers the same `tasks` read through the broker.
"task-tracker": Object.freeze({
kind: "task-tracker", interfaceVersion: 1, exports: Object.freeze(["adapter"]), switch: "one",
}),
});
export class PluginError extends Error {
@@ -0,0 +1,9 @@
// U4a (#1873, REQ-TASK-1): the default task-tracker module, the task store built into each
// organization's own bus. It needs no setting and no credential; packages/tasks/src/store.mjs has it.
import { storeAdapter } from '../../../tasks/src/store.mjs';
export const interfaceVersion = 1;
export function adapter() {
return storeAdapter();
}
@@ -0,0 +1,9 @@
// U4a (#1873): the optional Vikunja task-tracker module. It is the Vikunja adapter that was the only
// tracker before, unchanged, set up from the boot config's `trackers` (packages/tasks/src/adapter.mjs).
import { tasksAdapter } from '../../../tasks/src/adapter.mjs';
export const interfaceVersion = 1;
export function adapter({ trackers = {} } = {}) {
return tasksAdapter({ trackers });
}
+3 -1
View File
@@ -6,7 +6,9 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
const code = expected => e => e instanceof PluginError && e.code === expected;
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme"]);
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme", "task-tracker"]);
assert.deepEqual({ ...KINDS["task-tracker"], exports: [...KINDS["task-tracker"].exports] }, { kind: "task-tracker", interfaceVersion: 1, exports: ["adapter"], switch: "one" });
assert.ok(Object.isFrozen(KINDS["task-tracker"]) && Object.isFrozen(KINDS["task-tracker"].exports));
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
assert.deepEqual({ ...KINDS.theme, exports: [...KINDS.theme.exports] }, { kind: "theme", interfaceVersion: 1, exports: ["theme"], switch: "list" });
assert.ok(Object.isFrozen(KINDS.theme) && Object.isFrozen(KINDS.theme.exports));
+5 -5
View File
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] };
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] };
// A tree with one good module of each kind, already loaded.
async function loadedTree(t, options) {
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
const loader = await s.loader();
const result = await loader.load(business.plugins);
assert.deepEqual(result.refused, []);
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
assert.deepEqual(result.refused[0].kept, ["meter"]);
assert.deepEqual(s.loader.logged.slice(before), result.refused);
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
} finally {
if (lock) chmodSync(lock, 0o755);
}
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
const loader = await s.loader();
// meter loads, absent refuses: the whole kind stays off, not half on.
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.deepEqual(loader.loaded(), result.loaded);
assert.deepEqual(loader.modules("usage-source"), []);
assert.equal(loader.logged.length, 1);
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
// An absent key switches the kind off.
result = await s.loader.load({ "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
});
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
+183
View File
@@ -0,0 +1,183 @@
import { BusError, TASK_VERBS, ASSIGNED_STATES, STORE_POSITION_MAX } from '../../bus/src/broker.mjs';
import { loadFields } from './fields.mjs';
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
// module. It answers the eight task verbs as verbs.mjs does for Vikunja, with the same arguments
// where the store has the thing they name, and writes through broker.storeTask, which keeps the rows
// in the organization's own bus.sqlite. Nothing here talks to a network or holds a credential.
//
// What the module checks before the broker sees the call, in this order:
// - the caller is one of the organization's roles (tracker-role);
// - the field table's writer for the verb (packages/tasks/fields.json): a pm verb needs the pm
// definition (field-writer), task.update.assigned needs the role among the task's assignees
// (not-assigned), task.scope.change any role;
// - the arguments, as verbs.mjs takes them. The store has no Vikunja projects, relations or scoped
// requirement snapshots, so `project`, `relations`, `project_key` and `requirement_snapshot`
// refuse (invalid-request). task.schedule also takes `group` and `position`, task.create too.
// The broker then holds the write to the fields STORE_VERB_FIELDS gives the verb, whatever this
// module asked for, and checks the holder, the authority and a close's verdict as it does for Vikunja.
export const STORE_TIMEOUT = 60000;
const refuse = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
function keys(x, allowed, required = []) {
if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => x[k] === undefined))
refuse('invalid-request');
}
const text = (x, max, min = 0) => {
if (typeof x !== 'string' || x.length < min || x.length > max) refuse('invalid-request');
return x;
};
const canonical = (x) =>
typeof x === 'string' &&
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) &&
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
const due = (x) => (x === null || canonical(x) ? x : refuse('invalid-request'));
const priority = (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : refuse('invalid-request'));
const percent = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : refuse('invalid-request'));
const ids = (list) => {
if (!Array.isArray(list) || list.length > 50 || list.some((x) => !Number.isSafeInteger(x) || x < 1)) refuse('invalid-request');
return [...new Set(list)];
};
const group = (x) => (x === null || (typeof x === 'string' && x.length <= 64 && x.trim() && !/[\u0000-\u001f\u007f]/.test(x)) ? x : refuse('invalid-request'));
const position = (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : refuse('invalid-request'));
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
// Arguments a Vikunja verb takes that name something the store doesn't have.
const VIKUNJA_ONLY = ['project', 'relations', 'project_key', 'requirement_snapshot'];
const COMMON = ['task_ref', 'decision', 'expect'];
export function storeAdapter({ fields = loadFields() } = {}) {
const writers = fields.verbs;
return async function factory({ broker }) {
let closed = false;
function prepare(verb, args, roles, role) {
if (object(args) && VIKUNJA_ONLY.some((k) => Object.hasOwn(args, k))) refuse('invalid-request');
if (args?.expect !== undefined && !(typeof args.expect === 'string' && /^[0-9a-f]{64}$/.test(args.expect))) refuse('invalid-request');
switch (verb) {
case 'task.create': {
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'request', 'requirement', 'decision', 'op', 'group', 'position'], ['title', 'request', 'requirement']);
const set = { title: text(args.title, 250, 1) };
if (!set.title.trim()) refuse('invalid-request');
if (args.description !== undefined) set.description = text(args.description, 65536);
if (args.due_date !== undefined) set.due_date = due(args.due_date);
if (args.priority !== undefined) set.priority = priority(args.priority);
if (args.labels !== undefined) set.labels = ids(args.labels).sort((a, b) => a - b);
if (args.group !== undefined) set.group = group(args.group);
if (args.position !== undefined) set.position = position(args.position);
set.request = text(args.request, 64, 1);
set.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(set.requirement)) refuse('invalid-request');
if (args.op !== undefined) {
set.op = text(args.op, 256, 1);
if (/[;\r\n]/.test(set.op)) refuse('invalid-request');
}
return (f) => ({ ...f, ...set });
}
case 'task.assign':
case 'task.reassign': {
keys(args, [...COMMON, 'role'], ['task_ref', 'role']);
const to = text(args.role, 64, 1);
if (!Object.hasOwn(roles, to)) refuse('unknown-role');
return (f) => {
const held = f.assignees;
if (verb === 'task.assign' && held.length) refuse('task-assigned');
if (verb === 'task.reassign' && !held.length) refuse('task-unassigned');
if (verb === 'task.reassign' && held.length === 1 && held[0] === to) refuse('task-assigned');
return { ...f, assignees: [to] };
};
}
case 'task.schedule': {
keys(args, [...COMMON, 'due_date', 'labels', 'group', 'position'], ['task_ref']);
if (['due_date', 'labels', 'group', 'position'].every((k) => args[k] === undefined)) refuse('invalid-request');
const dueDate = args.due_date === undefined ? undefined : due(args.due_date);
const labels = { add: [], remove: [] };
if (args.labels !== undefined) {
keys(args.labels, ['add', 'remove']);
labels.add = ids(args.labels.add ?? []);
labels.remove = ids(args.labels.remove ?? []);
}
const g = args.group === undefined ? undefined : group(args.group);
const p = args.position === undefined ? undefined : position(args.position);
return (f) => {
const out = { ...f };
if (dueDate !== undefined) out.due_date = dueDate;
out.labels = [...new Set([...f.labels.filter((l) => !labels.remove.includes(l)), ...labels.add])].sort((a, b) => a - b);
// A move to another group with no position goes to the end of it (the broker settles a null).
if (g !== undefined && g !== f.group) Object.assign(out, { group: g, position: p ?? null });
else if (p !== undefined) out.position = p;
return out;
};
}
case 'task.priority.change': {
keys(args, [...COMMON, 'priority'], ['task_ref', 'priority']);
const p = priority(args.priority);
return (f) => ({ ...f, priority: p });
}
case 'task.scope.change': {
keys(args, [...COMMON, 'title', 'description'], ['task_ref']);
const set = {};
if (args.title !== undefined) set.title = text(args.title, 250, 1);
if (set.title !== undefined && !set.title.trim()) refuse('invalid-request');
if (args.description !== undefined) set.description = text(args.description, 65536);
if (!Object.keys(set).length) refuse('invalid-request');
return (f) => ({ ...f, ...set });
}
case 'task.update.assigned': {
keys(args, [...COMMON, 'percent_done', 'state', 'comment'], ['task_ref']);
const pct = args.percent_done === undefined ? undefined : percent(args.percent_done);
// done is not a target here: closing is the pm's task.close after a review verdict.
if (args.state !== undefined && !ASSIGNED_STATES.includes(args.state)) refuse('invalid-state');
const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1);
if (pct === undefined && args.state === undefined && note === undefined) refuse('invalid-request');
return (f) => {
if (!f.assignees.includes(role)) refuse('not-assigned');
const out = { ...f };
if (pct !== undefined) out.percent_done = pct;
if (args.state !== undefined) Object.assign(out, { state: args.state, bucket: args.state });
// The store keeps the latest comment and the count; the event records only that there was one.
if (note !== undefined) Object.assign(out, { comments: f.comments + 1, comment: { text: note, by: role } });
return out;
};
}
case 'task.close': {
keys(args, [...COMMON, 'verdict'], ['task_ref', 'verdict']);
const verdict = text(args.verdict, 256, 1);
return (f) => ({ ...f, state: 'done', bucket: 'done', done: true, verdict });
}
default:
return refuse('unknown-verb');
}
}
return {
timeout: STORE_TIMEOUT,
async handle(cap, verb, args) {
if (closed) refuse('tracker-closed');
const me = broker.identity(cap);
const roles = broker.taskRoles(me.business);
const mine = roles[me.role];
if (!mine || typeof mine.definition !== 'string') refuse('tracker-role');
if (!TASK_VERBS.includes(verb)) refuse('unknown-verb');
if (writers[verb] === 'pm' && mine.definition !== 'pm') refuse('field-writer');
const apply = prepare(verb, args, roles, me.role);
const creating = verb === 'task.create';
const row = broker.storeTask(cap, {
verb,
task_ref: creating ? null : args.task_ref,
decision: decision(args.decision),
expect: args.expect ?? null,
verdict: verb === 'task.close' ? args.verdict : null,
// Reopening a done task is a person's act; no role verb writes to one.
change: (f) => {
if (f?.done) refuse('task-done');
return apply(f);
},
});
return row.deleted ? { task_ref: row.task_ref, deleted: true } : { task_ref: row.task_ref, digest: row.digest, updated: row.updated };
},
close() {
closed = true;
},
};
};
}