Compare commits
7
Commits
org-tr
...
org-m-design
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69af2fe94b | ||
|
|
4106bf7344 | ||
|
|
8630b8dfc0 | ||
|
|
5eb6dda049 | ||
|
|
8710cc0e1e | ||
|
|
364f1ae7a6 | ||
|
|
9b7bf11afb |
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'), []);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -264,9 +264,32 @@
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null, messageCount = null;
|
||||
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Side by side, Agents and
|
||||
// Assistant, with Settings pinned to the foot of the sidebar. Queue opens from the organization
|
||||
// dashboard and Docs from its Docs row, so neither is a sidebar item here.
|
||||
function orgSections(route) {
|
||||
const nav = $('console-nav');
|
||||
let foot = $('sections-foot');
|
||||
if (!foot) { foot = document.createElement('div'); foot.id = 'sections-foot'; foot.className = 'ws-foot'; nav.append(foot); }
|
||||
const active = document.activeElement;
|
||||
const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null;
|
||||
const icon = id => window.MosaicWorkspace?.icon?.(id) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`;
|
||||
const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : '';
|
||||
const items = [['/', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', 'Assistant', n(messageCount, 'unread messages'), 'talk']];
|
||||
const link = ([h, l, extra, id]) => {
|
||||
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
return `<a href="#${h}" class="s1-section ws-nav"${cur ? ' aria-current="page"' : ''}>${icon(id)}<span class="t">${l}</span>${extra}</a>`;
|
||||
};
|
||||
// No project tree under Projects here: the sidebar's Projects group lists every organization's
|
||||
// projects and opens the same project page, which lists its conversations.
|
||||
$('sections').innerHTML = items.map(item => `<li>${link(item)}</li>`).join('');
|
||||
foot.innerHTML = link(['/settings', 'Settings', '', 'settings']);
|
||||
if (focused) [...$('sections').querySelectorAll('a[href]'), ...foot.querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
|
||||
}
|
||||
function sections(route) {
|
||||
if (window.MosaicOrg) return orgSections(route);
|
||||
const focused = $('sections').contains(document.activeElement) ? document.activeElement.getAttribute('href') : null;
|
||||
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Assistant', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
|
||||
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg${spriteVersion ? `?retry=${spriteVersion}` : ''}#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
|
||||
@@ -668,7 +691,12 @@
|
||||
}
|
||||
}
|
||||
async function agentsView(get) {
|
||||
const claims = await get('agents');
|
||||
let claims;
|
||||
try { claims = await get('agents'); } catch (err) {
|
||||
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
|
||||
if (refusal(err)) forget();
|
||||
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
|
||||
}
|
||||
let instances = null, threads = [];
|
||||
if (!isTester) {
|
||||
const reads = await Promise.allSettled([get('instances'), get.api('chat/threads')]);
|
||||
@@ -683,7 +711,9 @@
|
||||
for (const claim of claims) if (claim.holder_run && claim.role && !holders.has(claim.holder_run)) holders.set(claim.holder_run, claim.role);
|
||||
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||||
return `${head('Agents', readNote(get))}
|
||||
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
|
||||
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
|
||||
return `${top}
|
||||
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
|
||||
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
|
||||
${threadSection}
|
||||
@@ -753,7 +783,7 @@
|
||||
const b = (await get.api('business')).business || {};
|
||||
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
|
||||
if (orgs) await configRead; // orgSetup decides the "New organization" link
|
||||
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
|
||||
const picker = '';
|
||||
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
|
||||
const launch = b.launch || {};
|
||||
const authKnown = instances.some(i => i.authority);
|
||||
@@ -1172,7 +1202,7 @@
|
||||
const label=pages.find(([id])=>id===page)?.[1] || 'Appearance';
|
||||
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change));
|
||||
const dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';
|
||||
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('');
|
||||
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('') + (!isTester ? '<div class="settings-group">Members and invites <span class="muted">not built</span></div>' : '');
|
||||
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}${isDirty(page)?dot:''}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${readonlyPage(page)?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${readonlyPage(page)?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
|
||||
}
|
||||
async function settingsView(get, page='appearance') {
|
||||
@@ -1412,10 +1442,20 @@
|
||||
// ---------------------------------------------------------------- router
|
||||
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
|
||||
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
|
||||
// A one-line scope note right under a view's page heading.
|
||||
function afterHead(html, note) {
|
||||
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
|
||||
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
|
||||
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
|
||||
}
|
||||
function view(get, path, q, opts, current) {
|
||||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||||
if (parts.includes(null)) return notFound();
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return inboxView(get).then(html => afterHead(html, 'The inbox across all organizations is not built yet. This is the selected organization\'s inbox.'));
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'messages' && n === 1) return messagesView(get, q);
|
||||
@@ -1441,6 +1481,7 @@
|
||||
function keep() {
|
||||
const a = document.activeElement, view = $('bus-view');
|
||||
return {
|
||||
focusKey: view.contains(a) ? a.dataset?.focusKey || (a.hasAttribute?.('data-bus-restart') ? 'bus-restart' : null) : null,
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
|
||||
settingsChoice: view.contains(a) && a.type==='radio' ? [a.name,a.value] : null,
|
||||
settingsField: view.contains(a) && a.closest('.settings-content') && (a.dataset?.instanceField || a.dataset?.assistantField || a.dataset?.route || a.dataset?.setting) ? {form:a.closest('form')?.id || null, instance:a.closest('[data-instance]')?.dataset.instance || null, key:a.dataset.instanceField?'instanceField':a.dataset.assistantField?'assistantField':a.dataset.route?'route':'setting', value:a.dataset.instanceField || a.dataset.assistantField || a.dataset.route || a.dataset.setting} : null,
|
||||
@@ -1459,7 +1500,10 @@
|
||||
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
|
||||
const choice=k.settingsChoice && [...$('bus-view').querySelectorAll('input[type=radio]')].find(x=>x.name===k.settingsChoice[0]&&x.value===k.settingsChoice[1]);
|
||||
const setting=k.settingsField && [...$('bus-view').querySelectorAll('[data-instance-field],[data-assistant-field],[data-route],[data-setting]')].find(e=>e.dataset[k.settingsField.key]===k.settingsField.value && (!k.settingsField.form || e.closest('form')?.id===k.settingsField.form) && (!k.settingsField.instance || e.closest('[data-instance]')?.dataset.instance===k.settingsField.instance));
|
||||
if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
|
||||
// The workspace screens mark their controls with data-focus-key: several Open links share an href.
|
||||
const keyed = k.focusKey ? $('bus-view').querySelector(k.focusKey === 'bus-restart' ? '[data-bus-restart]' : `[data-focus-key="${CSS.escape(k.focusKey)}"]`) : null;
|
||||
if (keyed) keyed.focus({ preventScroll: true });
|
||||
else if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
|
||||
else if (choice) choice.focus({preventScroll:true});
|
||||
else if (dec) dec.focus({ preventScroll: true });
|
||||
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
@@ -1557,7 +1601,7 @@
|
||||
let html, state = 'fresh';
|
||||
const live = reader(true);
|
||||
try {
|
||||
if (path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
|
||||
if (path !== '/organization' && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
|
||||
try { await live('messages'); }
|
||||
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
}
|
||||
@@ -1578,6 +1622,7 @@
|
||||
$('bus-view').innerHTML = html; shown = route;
|
||||
syncAppearance(true);
|
||||
if (path.startsWith('/docs/')) paintDoc();
|
||||
if (path === '/organization') window.MosaicConsole?.attachBus(document.querySelector('[data-dashboard-bus]'));
|
||||
if (path === '/settings/model-proxy') proxyCleanup=window.MosaicProxy.mount();
|
||||
if (path === '/settings/organizations') orgCleanup=window.MosaicOrgCreate.mount();
|
||||
sections(path);
|
||||
@@ -1639,7 +1684,7 @@
|
||||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||||
// back where it was. It only navigates.
|
||||
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
|
||||
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Assistant'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
|
||||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||||
function pkItems() {
|
||||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div id="processing-mobile"></div>
|
||||
<div class="frame">
|
||||
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div>
|
||||
<ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<ul class="s1-sections" id="sections"></ul><div id="workspace-nav" aria-label="Organizations and projects"></div><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div id="theme-warning" class="banner ref" role="status" hidden><p></p><div class="actions"><button id="theme-clear" type="button" class="btn" hidden>Use the default theme</button></div></div><p id="org-cue" class="org-cue" hidden></p><nav class="breadcrumbs" id="breadcrumbs" aria-label="Breadcrumb"><span aria-current="page">Dashboard</span></nav><div class="page-head" id="board-head"><div><h1>Dashboard</h1><p id="dashboard-business" class="muted">Business overview</p><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
<section id="dashboard-summary" aria-label="Dashboard metrics"><div id="dashboard-kpis" class="kpi-grid"></div><p id="dashboard-updated" class="kpi-footer">Not read yet</p></section>
|
||||
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
|
||||
|
||||
@@ -351,3 +351,206 @@ body:not(:has(.project-chat)) .content { padding-bottom:calc(22px + var(--foot-h
|
||||
.settings-content:has(.route-actions){padding-bottom:100px}
|
||||
}
|
||||
.assistant-form textarea[data-assistant-field="vars"] { field-sizing:content; min-height:6em; max-height:16em; overflow-y:auto; }
|
||||
/* Unified workspace, organization mode (PRD 1.20, U2). The sample 01, 03 and 08 layouts, ported
|
||||
from docs/design/samples/2026-11-09-unified-workspace/workspace.css with ws- names, so nothing
|
||||
collides with the Console's own .btn, .chip, .panel, .split, .label or .cfg. */
|
||||
html[data-organizations]{--glyph:"DejaVu Sans","Segoe UI Symbol","Apple Symbols","Noto Sans Symbols 2",var(--font)}
|
||||
@media (min-width:960px){html[data-organizations]{--sidebar-width:240px}}
|
||||
/* An organization's colour for the page's mode: workspace.js sets the three pairs inline. */
|
||||
html[data-organizations] .ws-o{--org:var(--org-l);--org-on:var(--org-on-l)}
|
||||
@media (prefers-color-scheme:dark){html[data-organizations]:not([data-mode="light"]):not([data-mode="dim"]) .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}}
|
||||
html[data-organizations][data-mode="dark"] .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}
|
||||
html[data-organizations][data-mode="dim"] .ws-o{--org:var(--org-m);--org-on:var(--org-on-m)}
|
||||
html[data-organizations] .ws-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
html[data-organizations] .ws-label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
||||
html[data-organizations] .ws-line{margin:0;padding:3px 8px 3px 10px;font-size:12px;color:var(--muted);list-style:none}
|
||||
|
||||
/* Sidebar (sample 01). */
|
||||
html[data-organizations] .tree{padding:10px 8px 14px;gap:2px}
|
||||
html[data-organizations] #project-tree{border-bottom:0;padding-bottom:0}
|
||||
html[data-organizations] .drawer-head{background:var(--nav-surface,var(--surface))}
|
||||
html[data-organizations] #workspace-nav{display:flex;flex-direction:column;gap:2px;min-width:0;border:0;padding:0}
|
||||
html[data-organizations] .s1-sections{display:grid;gap:2px;flex-basis:auto;overflow:visible;padding:0}
|
||||
html[data-organizations] .s1-sections>li{width:100%;flex:none}
|
||||
html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{display:flex;align-items:center;gap:9px;padding:5px 8px;min-height:0;min-width:0;border:0;border-radius:var(--r);box-shadow:none;background:transparent;color:var(--text);text-decoration:none;font:600 13.5px/1.5 var(--font);flex-wrap:nowrap;white-space:nowrap}
|
||||
html[data-organizations] .ws-nav:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-nav[aria-current=page],html[data-organizations] .s1-section.ws-nav[aria-current=page]{background:color-mix(in srgb,var(--action) 13%,var(--surface));box-shadow:none;color:var(--strong)}
|
||||
html[data-organizations] .ws-nav svg.i{width:18px;height:18px;stroke-width:1.75;color:inherit}
|
||||
html[data-organizations] .ws-nav[aria-current=page] svg.i{color:var(--action)}
|
||||
html[data-organizations] .ws-nav .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-nav.sel{color:var(--strong)}
|
||||
html[data-organizations] .ws-n{margin-left:auto;font:600 11px var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
html[data-organizations] .ws-n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
|
||||
html[data-organizations] .ws-hr{border:0;border-top:1px solid var(--line);margin:6px 4px;width:auto}
|
||||
html[data-organizations] .ws-foot{margin-top:auto;padding-top:8px;border-top:1px solid var(--line)}
|
||||
html[data-organizations] .ws-grp{display:flex;flex-direction:column;gap:1px}
|
||||
html[data-organizations] .ws-grp-h{display:flex;align-items:center;gap:6px;padding:10px 8px 4px;color:var(--muted)}
|
||||
html[data-organizations] .ws-grp-t{display:flex;align-items:center;gap:6px;flex:1;min-width:0;padding:0;margin:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left}
|
||||
html[data-organizations] .ws-grp-t .ws-label{flex:1}
|
||||
html[data-organizations] .ws-grp-h>.ws-label{flex:1}
|
||||
html[data-organizations] .ws-grp-h svg.i{width:14px;height:14px;transition:transform .15s}
|
||||
html[data-organizations] .ws-grp-t[aria-expanded=false] svg.chev{transform:rotate(-90deg)}
|
||||
html[data-organizations] .ws-grp-h .ws-n{margin-left:0}
|
||||
html[data-organizations] .ws-add{display:inline-grid;place-items:center;flex:none;width:22px;height:22px;border-radius:var(--r);color:var(--muted);border:1px solid var(--line)}
|
||||
html[data-organizations] .ws-add:hover{background:var(--raised);color:var(--strong)}
|
||||
html[data-organizations] .ws-marks{display:flex;gap:4px;padding:0 8px 6px 28px}
|
||||
html[data-organizations] .ws-orgs{display:flex;flex-direction:column;gap:1px;list-style:none;margin:0;padding:0}
|
||||
html[data-organizations] .ws-orgs[hidden]{display:none}
|
||||
html[data-organizations] .ws-project-list[hidden]{display:none}
|
||||
html[data-organizations] .ws-seg{display:inline-flex;margin-left:auto;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-seg>button{border:0;background:none;padding:2px 8px;color:var(--muted);font:600 12px/1.5 var(--font);display:inline-flex;align-items:center;white-space:nowrap;cursor:pointer;min-height:0}
|
||||
html[data-organizations] .ws-seg.xs>button{padding:0 6px;font-size:11px}
|
||||
html[data-organizations] .ws-seg [aria-pressed=true]{background:var(--action);color:var(--onAction)}
|
||||
html[data-organizations] .ws-pgroup{display:flex;flex-direction:column;gap:1px;margin-bottom:4px}
|
||||
html[data-organizations] .ws-pgroup-h{display:flex;align-items:center;gap:7px;padding:4px 8px 2px;font-size:12px;font-weight:700;color:var(--strong)}
|
||||
html[data-organizations] .ws-pgroup-h .t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-projs{margin-left:17px;border-left:2px solid var(--org);display:flex;flex-direction:column;gap:1px}
|
||||
html[data-organizations] .ws-proj{padding:3px 8px 3px 10px;font-weight:500;font-size:13px}
|
||||
html[data-organizations] .ws-proj.flat{padding-left:8px}
|
||||
html[data-organizations] .ws-flat{display:flex;flex-direction:column;gap:1px}
|
||||
|
||||
/* Avatars, status marks, chips, buttons. */
|
||||
html[data-organizations] .ws-av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:5px;background:var(--org,var(--muted));color:var(--org-on,var(--surface));font:700 9.5px/1 var(--font);letter-spacing:.02em}
|
||||
html[data-organizations] .ws-av.xs{width:16px;height:16px;border-radius:4px;font-size:8.5px;letter-spacing:0}
|
||||
html[data-organizations] .ws-av.md{width:28px;height:28px;border-radius:7px;font-size:12px}
|
||||
html[data-organizations] .ws-av.lg{width:44px;height:44px;border-radius:10px;font-size:17px}
|
||||
html[data-organizations] .ws-av.all{background:var(--raised);color:var(--strong)}
|
||||
html[data-organizations] .ws-av.all .mosaic-logo{width:18px;height:18px;fill:currentColor}
|
||||
html[data-organizations] .ws-dot{display:inline-flex;align-items:center;gap:6px;flex:none}
|
||||
html[data-organizations] .ws-dot b{font:400 13px/1 var(--glyph);width:14px;text-align:center}
|
||||
html[data-organizations] .ws-dot.s-working{color:var(--success)}
|
||||
html[data-organizations] .ws-dot.s-waiting{color:var(--warning)}
|
||||
html[data-organizations] .ws-dot.s-error{color:var(--danger)}
|
||||
html[data-organizations] .ws-dot.s-offline,html[data-organizations] .ws-dot.s-idle,html[data-organizations] .ws-dot.s-unknown{color:var(--muted)}
|
||||
html[data-organizations] .ws-nav .ws-dot{margin-left:auto}
|
||||
html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)}
|
||||
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
|
||||
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
|
||||
/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the
|
||||
part that file repaints. */
|
||||
html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end}
|
||||
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px}
|
||||
html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none}
|
||||
html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
|
||||
/* Page frame (sample main column). */
|
||||
html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px}
|
||||
body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none}
|
||||
html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0}
|
||||
html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)}
|
||||
html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none}
|
||||
html[data-organizations] .ws h2{margin:0;font-size:15px;font-weight:700;color:var(--strong)}
|
||||
html[data-organizations] .ws code,html[data-organizations] .ws .mono{font-family:var(--mono);font-size:12.5px}
|
||||
html[data-organizations] .ws .muted,html[data-organizations] .ws .note{color:var(--muted)}
|
||||
html[data-organizations] .ws .note{font-size:13px}
|
||||
html[data-organizations] .ws-crumb{display:flex;gap:6px;align-items:center;font-size:12.5px;color:var(--muted);margin-bottom:4px}
|
||||
html[data-organizations] .ws-crumb a{color:var(--muted);text-decoration:none}
|
||||
html[data-organizations] .ws-crumb a:hover{color:var(--action);text-decoration:underline}
|
||||
html[data-organizations] .ws-vh{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-bottom:14px}
|
||||
html[data-organizations] .ws-vh .sub{flex-basis:100%;margin:0;font-size:13.5px;color:var(--muted)}
|
||||
html[data-organizations] .ws-right{margin-left:auto;display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px}
|
||||
html[data-organizations] .ws-sec-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;margin:22px 0 8px}
|
||||
html[data-organizations] .ws-sec-h .note{flex:1}
|
||||
html[data-organizations] .ws-sec-h a{margin-left:auto;font-size:13px;font-weight:600;color:var(--action);text-decoration:none}
|
||||
html[data-organizations] .ws-sec-h a:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-panel{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0}
|
||||
html[data-organizations] .ws-panel.tight{padding:12px 14px}
|
||||
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
|
||||
html[data-organizations] .ws-split{margin-top:22px}
|
||||
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
|
||||
|
||||
/* Organization dashboard (sample 03). */
|
||||
html[data-organizations] .ws-orghead{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:14px}
|
||||
html[data-organizations] .ws-orghead h1{display:flex;align-items:center;gap:10px}
|
||||
html[data-organizations] .ws-orghead h1 svg.i{width:20px;height:20px;color:var(--muted)}
|
||||
html[data-organizations] .ws-meta{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:center;gap:4px 14px;margin:-6px 0 0;padding-left:58px;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .ws-meta code{color:var(--text)}
|
||||
html[data-organizations] .ws-meta .ws-dot{color:var(--muted)}
|
||||
html[data-organizations] .ws-meta .ws-dot.s-working b{color:var(--success)}
|
||||
html[data-organizations] .ws-meta .ws-dot.s-offline b,html[data-organizations] .ws-meta .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-typechips,html[data-organizations] .ws-docrow{margin-top:8px}
|
||||
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:104px}
|
||||
html[data-organizations] .ws-doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none}
|
||||
html[data-organizations] .ws-doc:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
|
||||
html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px}
|
||||
html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none}
|
||||
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
|
||||
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent}
|
||||
html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
|
||||
html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0}
|
||||
html[data-organizations] .ws-tbl th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);background:none;white-space:nowrap}
|
||||
html[data-organizations] .ws-tbl td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:middle}
|
||||
html[data-organizations] .ws-tbl tr:last-child td{border-bottom:0}
|
||||
html[data-organizations] .ws-tbl .t1{color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl .t2{display:block;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-tbl td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-cfg li{display:grid;align-items:center;grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-cfg li:last-child{border-bottom:0}
|
||||
html[data-organizations] .ws-cfg svg.i{width:16px;height:16px;color:var(--muted)}
|
||||
html[data-organizations] .ws-cfg .k{font-size:13px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg .src-tag{font:500 11px var(--mono);color:var(--accent);white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg .go{font-size:12.5px;font-weight:600;color:var(--action);text-decoration:none;min-width:32px;text-align:right}
|
||||
html[data-organizations] .ws-cfg a.go:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-cfg li.danger .k,html[data-organizations] .ws-cfg li.danger svg.i,html[data-organizations] .ws-cfg li.danger .go,html[data-organizations] .ws-cfg li.danger .src-tag{color:var(--danger)}
|
||||
|
||||
/* Agents (sample 08). */
|
||||
html[data-organizations] .ws-lvl{display:inline-block;font:700 10.5px/1.5 var(--font);letter-spacing:.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;margin-right:6px;vertical-align:1px}
|
||||
html[data-organizations] .ws-lvl.root{background:var(--strong);color:var(--surface)}
|
||||
html[data-organizations] .ws-lvl.org{background:color-mix(in srgb,var(--org,var(--action)) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--org,var(--action)) 50%,transparent);color:var(--strong)}
|
||||
html[data-organizations] .ws-lvl.proj{box-shadow:inset 0 0 0 1px var(--line);color:var(--text)}
|
||||
html[data-organizations] .ws-span-band{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;padding:8px 12px;margin-bottom:14px;border-radius:8px;border:1px solid var(--action);background:color-mix(in srgb,var(--action) 8%,var(--surface));font-size:13px;color:var(--strong)}
|
||||
html[data-organizations] .ws-span-band .orgs{display:inline-flex;gap:4px}
|
||||
html[data-organizations] .ws-band-note{flex-basis:100%;font-size:12.5px}
|
||||
html[data-organizations] .ws-levels{display:grid;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-levels>div{padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent);min-width:0}
|
||||
html[data-organizations] .ws-levels>.endc{border-right:0}
|
||||
html[data-organizations] .ws-levels>.last{border-bottom:0}
|
||||
html[data-organizations] .ws-levels>.lh{background:color-mix(in srgb,var(--raised) 45%,transparent);display:grid;gap:4px;align-content:start}
|
||||
html[data-organizations] .ws-levels>.lh .ws-lvl{justify-self:start}
|
||||
html[data-organizations] .ws-levels>.lh small{font-size:11.5px;color:var(--muted);line-height:1.4}
|
||||
html[data-organizations] .ws-levels>.oh{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--strong);background:color-mix(in srgb,var(--raised) 45%,transparent)}
|
||||
html[data-organizations] .ws-levels>.span{grid-column:2/-1;display:flex;align-items:center}
|
||||
html[data-organizations] .ws-levels .mlabel{display:none;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--strong);margin-bottom:4px}
|
||||
html[data-organizations] .ws-agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;background:var(--canvas);margin:2px 4px 2px 0;color:var(--strong)}
|
||||
html[data-organizations] .ws-agent small{color:var(--muted);font-weight:500;font-size:11px}
|
||||
html[data-organizations] .ws-agent .ws-dot b{font-size:11px;width:10px}
|
||||
html[data-organizations] .ws-rules{display:grid;gap:10px;font-size:13px}
|
||||
html[data-organizations] .ws-rules p{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:0}
|
||||
html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
|
||||
html[data-organizations] .ws-scope{margin:-8px 0 14px}
|
||||
|
||||
@media (pointer:coarse){html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{min-height:40px}}
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
|
||||
html[data-organizations] .tree{padding:10px 12px 20px}
|
||||
html[data-organizations] .ws h1{font-size:24px}
|
||||
html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:auto}
|
||||
html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3}
|
||||
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
|
||||
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
|
||||
html[data-organizations] .ws-meta{padding-left:0;margin-top:0}
|
||||
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
|
||||
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
|
||||
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0}
|
||||
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
|
||||
html[data-organizations] .ws-levels>div{border-right:0}
|
||||
html[data-organizations] .ws-levels>.hide-m,html[data-organizations] .ws .hide-m{display:none}
|
||||
html[data-organizations] .ws-levels>.span{grid-column:auto}
|
||||
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-levels .mlabel{display:flex}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Every read the unified workspace screens make: the organization list, the theme table and the
|
||||
// per-organization routes under /api/orgs/<id>/. Nothing here writes. A read that fails throws, so
|
||||
// each caller says "unavailable" for that one part instead of guessing.
|
||||
(() => {
|
||||
const read = async path => {
|
||||
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||||
if (!response.ok || response.type === 'opaqueredirect') throw new Error('Read unavailable');
|
||||
return response.json();
|
||||
};
|
||||
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
|
||||
const organizations = () => read('/api/orgs');
|
||||
const themes = () => read('/api/themes');
|
||||
const projects = id => read(orgPath(id, 'chat/projects'));
|
||||
const threads = id => read(orgPath(id, 'chat/threads'));
|
||||
const instances = id => read(orgPath(id, 'bus/instances'));
|
||||
// Held roles with the state the bus derives (working, waiting, idle), and which run holds which thread.
|
||||
const agents = id => read(orgPath(id, 'bus/agents'));
|
||||
const claims = id => read(orgPath(id, 'bus/thread.claims'));
|
||||
const queue = id => read(orgPath(id, 'queue'));
|
||||
const business = id => read(orgPath(id, 'business'));
|
||||
const settings = id => read(orgPath(id, 'settings'));
|
||||
const projectFiles = (id, project) => read(orgPath(id, `project-files?project=${encodeURIComponent(project)}`));
|
||||
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles });
|
||||
})();
|
||||
@@ -0,0 +1,299 @@
|
||||
// The unified workspace screens in organization mode: the sidebar's Organizations and Projects
|
||||
// groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08).
|
||||
// Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus
|
||||
// restart button is console-restart.js's own control, with its gating and trail unchanged.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const GROUPING = 'mosaic-workspace-project-grouping-v1', ORGS_OPEN = 'mosaic-workspace-orgs-open-v1', PROJECTS_OPEN = 'mosaic-workspace-projects-open-v1';
|
||||
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
|
||||
const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
|
||||
let grouping = load(GROUPING) === 'flat' ? 'flat' : 'org';
|
||||
let expanded = load(ORGS_OPEN) !== 'closed';
|
||||
let projectsOpen = load(PROJECTS_OPEN) !== 'closed';
|
||||
let orgs = null, orgError = false, themes = null, projects = new Map();
|
||||
|
||||
// Icons: the Console sprite where it has one, inline paths from the U0 sample where it does not.
|
||||
const PATHS = {
|
||||
folder: '<path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/>',
|
||||
columns: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>',
|
||||
chevron: '<path d="m6 9 6 6 6-6"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
restart: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/>',
|
||||
doc: '<path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/>',
|
||||
people: '<circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0M15.5 5.8a3 3 0 0 1 0 5.4M17.5 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
|
||||
cube: '<path d="M12 3.5 20 8v8l-8 4.5L4 16V8z"/><path d="m4 8 8 4.5L20 8M12 12.5v8"/>',
|
||||
};
|
||||
const icon = (id, cls = '') => `<svg class="i${cls ? ` ${cls}` : ''}"${PATHS[id] ? ' viewBox="0 0 24 24"' : ''} aria-hidden="true" focusable="false">${PATHS[id] || `<use href="/icons.svg#i-${id}"/>`}</svg>`;
|
||||
const LOGO = '<svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg>';
|
||||
|
||||
// An organization's colour is its theme's action colour, per mode, with that mode's onAction for
|
||||
// the initials. The CSS picks the pair for the page's mode (.ws-o in shell.css).
|
||||
const HEX = /^#[0-9a-f]{6}$/i;
|
||||
const themeOf = org => (themes || []).find(t => t.id === (org.theme || 'harbor')) || (themes || []).find(t => t.id === 'harbor');
|
||||
function colours(org) {
|
||||
const tokens = themeOf(org)?.tokens || {};
|
||||
const pick = (mode, name, fallback) => (HEX.test(tokens[mode]?.[name] || '') ? tokens[mode][name] : fallback);
|
||||
const l = [pick('light', '--action', '#2266bf'), pick('light', '--onAction', '#ffffff')];
|
||||
const d = [pick('dark', '--action', '#74a6e7'), pick('dark', '--onAction', '#10141a')];
|
||||
const m = [pick('dim', '--action', d[0]), pick('dim', '--onAction', d[1])];
|
||||
return `--org-l:${l[0]};--org-on-l:${l[1]};--org-d:${d[0]};--org-on-d:${d[1]};--org-m:${m[0]};--org-on-m:${m[1]}`;
|
||||
}
|
||||
// Two words give their first letters (Distinctly Digital, DD); one word gives its first letter and
|
||||
// its last consonant (Forge, FG; Home, HM), as the U0 sample marks them.
|
||||
function initials(org) {
|
||||
const words = String(org.label || org.id || '?').trim().split(/[\s_.-]+/).filter(Boolean);
|
||||
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
|
||||
const word = words[0] || '?', rest = word.slice(1).replace(/[^a-z0-9]/gi, ''), consonants = rest.match(/[bcdfghjklmnpqrstvwxz]/gi);
|
||||
return (word[0] + (consonants ? consonants[consonants.length - 1] : rest[0] || '')).toUpperCase();
|
||||
}
|
||||
const av = (org, size = '') => `<span class="ws-av ws-o${size ? ` ${size}` : ''}" style="${colours(org)}" aria-hidden="true">${esc(initials(org))}</span>`;
|
||||
const label = org => org.label || org.id;
|
||||
const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : ['s-offline', '○', 'bus unavailable']);
|
||||
// A status mark: a glyph plus its word, shown or read aloud only.
|
||||
const dot = (cls, glyph, word, shown = false) => `<span class="ws-dot ${cls}" title="${esc(word)}"><b aria-hidden="true">${glyph}</b>${shown ? esc(word) : `<span class="ws-sr">${esc(word)}</span>`}</span>`;
|
||||
const orgHref = id => `#/orgs/${enc(id)}`;
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
|
||||
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
|
||||
const running = item => (Array.isArray(item?.running) ? item.running.length : 0);
|
||||
const rowsOf = v => (Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : null);
|
||||
// An agent's activity is the state the bus derives for the run holding its role. A live process
|
||||
// alone says only running, never working or idle.
|
||||
const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] };
|
||||
const activity = (name, item, agents) => {
|
||||
const on = running(item) > 0;
|
||||
const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null;
|
||||
return ACTIVITY[held?.state] || (on ? ['s-working', '●', 'running'] : ['s-idle', '–', 'not running']);
|
||||
};
|
||||
// The inline model control: the change itself happens on Settings, Routing, with its own checks.
|
||||
const modelCell = (agent, model) => (model ? `${esc(model)} <a class="ws-change" href="#/settings/routing" data-focus-key="${esc(`model:${agent}`)}" aria-label="${esc(`Change the model for ${agent}`)}">Change</a>` : '<span class="muted">not reported</span>');
|
||||
|
||||
// Focus survives a repaint: the focused control's data-focus-key is found again afterwards.
|
||||
const focusKey = root => { const a = document.activeElement; return root && a && root.contains(a) ? a.dataset?.focusKey || null : null; };
|
||||
const refocus = (root, key) => { if (key) root.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true }); };
|
||||
|
||||
// ---------- sidebar (sample 01) ----------
|
||||
function projectLinks(org, flat) {
|
||||
const rows = projects.get(org.id);
|
||||
if (rows === undefined) return flat ? '' : '<p class="ws-line">Reading projects…</p>';
|
||||
if (rows === null) return `<p class="ws-line">${flat ? `${esc(label(org))}: projects unavailable` : 'Projects unavailable'}</p>`;
|
||||
const live = rows.filter(p => p && !p.archived);
|
||||
if (!live.length) return flat ? '' : '<p class="ws-line">No projects yet</p>';
|
||||
const hash = location.hash;
|
||||
return live.map(p => {
|
||||
const href = `${orgHref(org.id)}/projects/${enc(p.id)}`, current = hash === href || hash.startsWith(`${href}/`);
|
||||
return `<a class="ws-nav ws-proj${flat ? ' flat' : ''}" href="${esc(href)}" data-focus-key="${esc(`proj:${org.id}:${p.id}`)}"${current ? ' aria-current="page"' : ''}>${flat ? av(org, 'xs') : ''}<span class="t">${esc(p.name || p.id)}</span></a>`;
|
||||
}).join('');
|
||||
}
|
||||
function projectGroups(list) {
|
||||
if (grouping === 'flat') {
|
||||
const body = list.map(org => projectLinks(org, true)).join('');
|
||||
if (body) return `<div class="ws-flat">${body}</div>`;
|
||||
return list.some(org => projects.get(org.id) === undefined) ? '<p class="ws-line">Reading projects…</p>' : '<p class="ws-line">No projects yet</p>';
|
||||
}
|
||||
return list.map(org => `<div class="ws-pgroup ws-o" style="${colours(org)}" data-org="${esc(org.id)}"><div class="ws-pgroup-h">${av(org, 'xs')}<span class="t">${esc(label(org))}</span></div><div class="ws-projs">${projectLinks(org, false)}</div></div>`).join('') || '<p class="ws-line">No projects yet</p>';
|
||||
}
|
||||
function paint() {
|
||||
const root = document.getElementById('workspace-nav'); if (!root) return;
|
||||
const key = focusKey(root);
|
||||
if (orgError || !orgs) { root.innerHTML = `<hr class="ws-hr"><p class="ws-line">${orgError ? 'Organizations unavailable' : 'Reading organizations…'}</p>`; return; }
|
||||
const here = route(), selected = window.MosaicOrg?.id;
|
||||
const rows = orgs.map(org => {
|
||||
const [cls, glyph, word] = state(org), mine = org.id === selected, current = mine && here === '/organization';
|
||||
return `<li data-workspace-org="${esc(org.id)}"><a class="ws-nav ws-org-row${mine && !current ? ' sel' : ''}" href="${esc(orgHref(org.id))}/organization" data-focus-key="${esc(`org:${org.id}`)}"${current ? ' aria-current="page"' : ''}>${av(org)}<span class="t">${esc(label(org))}</span>${dot(cls, glyph, word, cls !== 's-working')}</a></li>`;
|
||||
}).join('');
|
||||
const marks = expanded || !orgs.length ? '' : `<div class="ws-marks" aria-hidden="true">${orgs.map(org => av(org, 'xs')).join('')}</div>`;
|
||||
root.innerHTML = `<hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-org-toggle data-focus-key="org-toggle" aria-expanded="${expanded}" aria-controls="ws-org-list">${icon('chevron', 'chev')}<span class="ws-label">Organizations</span><span class="ws-n">${orgs.length}</span></button><a class="ws-add" href="#/settings/organizations?new=1" data-focus-key="org-new" aria-label="New organization" title="New organization">${icon('plus')}</a></div>${marks}<ul class="ws-orgs" id="ws-org-list" data-org-list${expanded ? '' : ' hidden'}>${rows || '<li class="ws-line">No organizations yet</li>'}</ul></div><hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-projects-toggle data-focus-key="projects-toggle" aria-expanded="${projectsOpen}" aria-controls="ws-project-list">${icon('chevron', 'chev')}<span class="ws-label">Projects</span></button><span class="ws-seg xs" role="group" aria-label="Project layout"><button type="button" data-group="org" data-focus-key="group-org" aria-pressed="${grouping === 'org'}">By org</button><button type="button" data-group="flat" data-focus-key="group-flat" aria-pressed="${grouping === 'flat'}">Flat</button></span></div><div class="ws-project-list" id="ws-project-list"${projectsOpen ? '' : ' hidden'}>${projectGroups(orgs.filter(org => !org.invalid))}</div></div>`;
|
||||
refocus(root, key);
|
||||
}
|
||||
let reading = 0;
|
||||
async function refresh(rows) {
|
||||
const turn = ++reading;
|
||||
try {
|
||||
const [list] = await Promise.all([Array.isArray(rows) ? rows : data.organizations(), ensureThemes()]);
|
||||
if (turn !== reading) return;
|
||||
orgs = Array.isArray(list) ? list : []; orgError = false;
|
||||
} catch { if (turn === reading) { orgError = true; paint(); } return; }
|
||||
paint();
|
||||
const results = await Promise.all(orgs.filter(org => !org.invalid).map(org => data.projects(org.id).then(b => [org.id, Array.isArray(b?.rows) ? b.rows : null]).catch(() => [org.id, null])));
|
||||
if (turn !== reading) return;
|
||||
projects = new Map(results); paint();
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const toggle = e.target.closest?.('[data-org-toggle]');
|
||||
if (toggle) { expanded = !expanded; save(ORGS_OPEN, expanded ? 'open' : 'closed'); paint(); document.querySelector('[data-org-toggle]')?.focus(); return; }
|
||||
if (e.target.closest?.('[data-projects-toggle]')) { projectsOpen = !projectsOpen; save(PROJECTS_OPEN, projectsOpen ? 'open' : 'closed'); paint(); document.querySelector('[data-projects-toggle]')?.focus(); return; }
|
||||
const group = e.target.closest?.('#workspace-nav [data-group]');
|
||||
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); }
|
||||
});
|
||||
window.addEventListener('hashchange', () => { if (orgs) paint(); });
|
||||
document.addEventListener('mosaic-org-list', e => refresh(e.detail?.rows));
|
||||
|
||||
// ---------- organization dashboard (sample 03) ----------
|
||||
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/;
|
||||
const DOC_PATH = /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/;
|
||||
const docCache = new Map();
|
||||
// Docs are read at most once a minute per organization: the dashboard repaints every 10 s.
|
||||
async function docs(id, names) {
|
||||
const hit = docCache.get(id);
|
||||
if (hit && Date.now() - hit.at < 60000) return hit.value;
|
||||
const list = (Array.isArray(names) ? names : []).filter(p => typeof p === 'string' && SAFE.test(p)).slice(0, 6);
|
||||
const read = await Promise.allSettled(list.map(p => data.projectFiles(id, p).then(body => (Array.isArray(body?.files) ? body.files : []).filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)).map(f => ({ project: p, path: f.path })))));
|
||||
const value = { rows: read.flatMap(r => (r.status === 'fulfilled' ? r.value : [])), failed: read.length > 0 && read.every(r => r.status === 'rejected') };
|
||||
docCache.set(id, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
const docName = path => (path === 'spec/north-star.md' ? 'North star' : path.replace(/^.*\//, '').replace(/\.md$/, '').replace(/[-_]+/g, ' ').replace(/^./, c => c.toUpperCase()));
|
||||
const titleCase = s => String(s || '').replace(/(^|[-_\s])(\w)/g, (_, sep, c) => `${sep ? ' ' : ''}${c.toUpperCase()}`);
|
||||
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
|
||||
|
||||
function settingsList(org, ctx) {
|
||||
const { business, settings, instances, orgCount } = ctx;
|
||||
const b = business?.business, s = settings;
|
||||
const unread = 'Could not be read';
|
||||
const names = instances ? Object.keys(instances) : null;
|
||||
const models = b && Array.isArray(b.instances) ? [...new Set(b.instances.map(i => i?.vars?.model).filter(m => typeof m === 'string' && m))] : null;
|
||||
const arbiters = b?.arbiters && typeof b.arbiters === 'object' ? Object.entries(b.arbiters).filter(([, v]) => typeof v === 'string').map(([k, v]) => `${v} (${k})`).join(' · ') : null;
|
||||
const total = s?.launch && !s.launch.refused ? s.launch.limits?.total : undefined;
|
||||
const notifier = s?.notifier;
|
||||
const theme = b?.appearance?.theme || org.theme || 'harbor';
|
||||
const rows = [
|
||||
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
|
||||
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
|
||||
['cube', 'System types', 'Not built yet', 'not built', null],
|
||||
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
|
||||
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
|
||||
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
|
||||
['queue', 'Arbiters', b ? arbiters || 'None set' : unread, 'org file', 'business'],
|
||||
['tasks', 'Task tracker', b ? (b.vars?.['tracker.kind'] ? titleCase(b.vars['tracker.kind']) : 'Not set') : unread, 'org file', 'business'],
|
||||
['talk', 'Channels', !s ? unread : typeof notifier?.binding === 'string' ? `Notifier ${notifier.binding}` : notifier?.refused ? unread : 'No notifier set', 'system', 'about'],
|
||||
['ledger', 'Launch limits', !s ? unread : s.launch?.refused ? unread : Number.isFinite(total) ? `${plural(total, 'session')} at once` : 'No session limit', 'org file', 'limits'],
|
||||
['talk', 'Assistant', 'The assistant agent and its seat names', 'org file', 'assistant'],
|
||||
['ledger', 'Token use', 'Usage and limits per provider and model', 'system', 'tokens'],
|
||||
['term', 'Model proxy', 'Accounts, priority and client keys', 'system', 'model-proxy'],
|
||||
['restart', 'Console', 'Console restart', 'system', 'console'],
|
||||
['business', 'Organizations', orgCount === null ? unread : plural(orgCount, 'organization'), 'system', 'organizations'],
|
||||
['flag', 'About', typeof s?.release === 'string' ? `Release ${s.release}` : 'Address, release and notifier', 'system', 'about'],
|
||||
['people', 'Members and invites', 'Not built yet', 'not built', null],
|
||||
['restart', 'Bus', `${org.busHealthy ? 'Running' : 'Unavailable'} · restart from this page's header`, 'system', null],
|
||||
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
|
||||
];
|
||||
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
|
||||
const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
|
||||
const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
|
||||
return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`;
|
||||
}).join('')}</ul>`;
|
||||
}
|
||||
|
||||
async function dashboard() {
|
||||
const id = window.MosaicOrg.id;
|
||||
// Only an organization the membership list names is read; anything else reads nothing more.
|
||||
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
|
||||
const list = Array.isArray(listRead) ? listRead : null;
|
||||
const org = list?.find(o => o.id === id && !o.invalid);
|
||||
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id)]);
|
||||
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
|
||||
const business = value(0), settings = value(1), instances = instancesOf(value(2));
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
|
||||
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
|
||||
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
|
||||
await window.MosaicConsole?.ready;
|
||||
const name = esc(label(org));
|
||||
const tracker = business?.business?.vars?.['tracker.kind'];
|
||||
|
||||
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
|
||||
// the same control, present and disabled, with the reason.
|
||||
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
|
||||
const [bcls, bglyph] = state(org);
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
|
||||
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
|
||||
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
|
||||
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
|
||||
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
|
||||
|
||||
const tiles = (title, note, empty) => `${secH(title, note)}<div class="ws-tiles"><div class="ws-tile is-empty"><span class="ws-empty-t">${empty}</span></div></div>`;
|
||||
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
|
||||
const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null;
|
||||
// A session's project and task is the thread its live run holds a claim on (thread.claims), not
|
||||
// any older conversation of the same agent. A failed read says unavailable, never "none".
|
||||
const sessionRows = (live || []).map(([agent, item]) => {
|
||||
const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null;
|
||||
const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null;
|
||||
const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents);
|
||||
const more = held && held.length > 1 ? ` · ${held.length - 1} more` : '';
|
||||
const where = held === null ? '<span class="muted">Conversation unavailable</span>'
|
||||
: !first ? '<span class="muted">No conversation held right now</span>'
|
||||
: thread ? `${esc(projectName(thread.project))}<span class="t2">${esc(thread.name || thread.id)}${more}</span>`
|
||||
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
|
||||
return `<tr><td>${esc(agent)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
|
||||
}).join('');
|
||||
const sessions = live === null ? '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>' : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
|
||||
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
|
||||
const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows';
|
||||
|
||||
return `<article class="ws ws-dash" data-org-dashboard>
|
||||
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/settings/organizations">Organizations</a><span aria-hidden="true">›</span><span>${name}</span></nav>
|
||||
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
|
||||
${typechips}${docrow}
|
||||
<div class="ws-split">
|
||||
<section aria-label="Measures and sessions">
|
||||
${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')}
|
||||
${tiles('Metrics', 'measured by the bus', 'No metrics recorded yet')}
|
||||
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', '<a href="#/agents" data-focus-key="agents-all">All agents</a>')}
|
||||
${sessions}
|
||||
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
|
||||
</section>
|
||||
<section aria-label="Settings">
|
||||
${secH('Settings', 'every setting, with where its value comes from')}
|
||||
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
|
||||
</section>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// ---------- agents levels (sample 08) ----------
|
||||
async function agentGrid(note = '') {
|
||||
const [list] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
|
||||
const selected = window.MosaicOrg?.id;
|
||||
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Only organization agents can be read today.${note ? ` ${note}` : ''}</p></div>`;
|
||||
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
|
||||
const valid = list.filter(org => !org.invalid);
|
||||
const admitted = valid.some(org => org.id === selected);
|
||||
const [results, business] = await Promise.all([
|
||||
Promise.all(valid.map(org => Promise.all([data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null)]).then(([inst, agents]) => ({ org, inst, agents })))),
|
||||
admitted ? data.business(selected).catch(() => null) : null,
|
||||
]);
|
||||
const band = `<div class="ws-span-band"><span class="ws-av all md" aria-hidden="true">${LOGO}</span><b>assistant</b><span class="ws-lvl root">root</span>${dot('s-unknown', '?', 'status not reported', true)}<span>Works across</span><span class="orgs">${valid.map(org => av(org)).join('')}</span><span class="muted ws-band-note">The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.</span></div>`;
|
||||
const n = Math.max(valid.length, 1);
|
||||
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
|
||||
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
|
||||
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', i === results.length - 1 ? ' endc' : '')).join('');
|
||||
const levels = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
|
||||
<div class="lh hide-m"></div>${valid.map((org, i) => `<div class="oh hide-m ws-o${i === valid.length - 1 ? ' endc' : ''}" style="${colours(org)}">${av(org)}${esc(label(org))}</div>`).join('')}
|
||||
<div class="lh"><span class="ws-lvl root">root</span><small>Your root configuration, shared by every organization.</small></div><div class="span endc"><span class="muted">Root agents are not available yet.</span></div>
|
||||
<div class="lh"><span class="ws-lvl org">org</span><small>Defined in the organization file. Each organization's agents work only inside it.</small></div>${orgRow}
|
||||
<div class="lh last"><span class="ws-lvl proj">project</span><small>Defined in one project and available only there.</small></div><div class="span endc last"><span class="muted">Project agents are not available yet.</span></div>
|
||||
</div>` : '<p class="ws-panel tight note">No organizations yet.</p>';
|
||||
const mine = results.find(r => r.org.id === selected);
|
||||
const selOrg = list.find(o => o.id === selected) || { id: selected, label: selected };
|
||||
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
|
||||
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : mine.inst === null ? '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>' : !Object.keys(mine.inst).length ? '<p class="ws-panel tight note">No agents configured</p>' : `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${Object.entries(mine.inst).map(([k, v]) => { const [acls, aglyph, aword] = activity(k, v, mine.agents), model = modelOf(k); return `<tr><td class="t1">${esc(k)}${v?.definition && v.definition !== k ? `<span class="t2">${esc(v.definition)}</span>` : ''}</td><td><span class="ws-lvl org ws-o" style="${colours(selOrg)}">org</span></td><td class="hide-m">${esc(label(selOrg))} file</td><td>${dot(acls, aglyph, aword, true)}</td><td class="hide-m mono">${modelCell(k, model)}</td></tr>`; }).join('')}</tbody></table></div>`;
|
||||
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span><span class="ws-lvl root">root</span>Not built yet, so nothing can be defined at these levels.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
|
||||
return `<section class="ws ws-agents" data-org-agents>${head}${band}${levels}
|
||||
<div class="ws-split ws-split-agents"><section>${secH(`Agents in ${esc(label(selOrg))}`, 'organization level', '')}${table}</section><section>${secH('Who changes what')}${rules}</section></div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh() });
|
||||
refresh();
|
||||
})();
|
||||
@@ -58,7 +58,7 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -137,14 +137,15 @@ for (const [status, body] of [[201, { outcome: 'done', step: 'delete' }], [503,
|
||||
});
|
||||
}
|
||||
|
||||
test('review: deleting a non-current org refreshes the organization switcher', async t => {
|
||||
// U2 (#1873): the sidebar's Organizations group replaced the organization switcher.
|
||||
test('review: deleting a non-current org refreshes the sidebar organization list', async t => {
|
||||
const f = await reviewFixture(t);
|
||||
await f.confirm('beta');
|
||||
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha']);
|
||||
await f.b.evaluate(`location.hash='#/orgs/alpha/settings/business'`);
|
||||
await f.wait(`!!document.querySelector('#org-picker')`);
|
||||
assert.deepEqual(await f.b.evaluate(`[...document.querySelector('#org-picker').options].map(e=>e.value)`), ['alpha']);
|
||||
assert.equal(await f.b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
|
||||
await f.b.evaluate(`location.hash='#/orgs/alpha/organization'`);
|
||||
await f.wait(`document.querySelectorAll('[data-workspace-org]').length === 1`);
|
||||
assert.deepEqual(await f.b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(e=>e.dataset.workspaceOrg)`), ['alpha']);
|
||||
assert.equal(await f.b.evaluate(`document.querySelector('[data-workspace-org="alpha"] a').getAttribute('aria-current')`), 'page');
|
||||
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows => rows.map(row => row.id))`), ['alpha']);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,15 +60,16 @@ for (const [width, height] of [[1400, 900], [390, 844]]) {
|
||||
});
|
||||
}
|
||||
|
||||
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
|
||||
// U2 (#1873): the sidebar's Organizations group replaced the organization picker.
|
||||
test('the sidebar Organizations group offers New organization', { timeout: 60000 }, async t => {
|
||||
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
|
||||
await b.viewport(1400, 900);
|
||||
await b.navigate(`${base}#/orgs/alpha/settings/business`);
|
||||
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
|
||||
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
|
||||
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
|
||||
const link = `document.querySelector('#workspace-nav a.ws-add[href="#/settings/organizations?new=1"]')`;
|
||||
await wait(`document.querySelectorAll('[data-workspace-org]').length === 2 && ${link}`, 'sidebar link');
|
||||
assert.equal(await b.evaluate(`${link}.getAttribute('aria-label')`), 'New organization');
|
||||
await b.evaluate(`${link}.click()`);
|
||||
await formFocused('#/orgs/alpha', 'picker');
|
||||
await formFocused('#/orgs/alpha', 'sidebar');
|
||||
});
|
||||
|
||||
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
|
||||
|
||||
@@ -128,7 +128,7 @@ test('project creation offers repositories from the scoped business read', async
|
||||
assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work');
|
||||
});
|
||||
|
||||
test('Settings lists organizations and switching changes the route and header', async t => {
|
||||
test('sidebar lists organizations and navigating changes the route and header', async t => {
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
|
||||
resolve: async id => ['alpha', 'beta'].includes(id) ? {
|
||||
@@ -141,11 +141,12 @@ test('Settings lists organizations and switching changes the route and header',
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(390, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#org-picker'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organization picker did not appear')})()`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelectorAll('[data-workspace-org]').length===2 && document.querySelector('#settings-page-title'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organizations did not appear')})()`);
|
||||
assert.match(await b.evaluate(`document.querySelector('header').textContent`), /Alpha/);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization');
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelector('#org-picker').options].map(o=>o.value)`), ['alpha', 'beta']);
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('#org-picker');e.value='beta';e.dispatchEvent(new Event('change',{bubbles:true}))})()`);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#org-picker')`), false);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(o=>o.dataset.workspaceOrg)`), ['alpha', 'beta']);
|
||||
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
|
||||
let switched = false;
|
||||
for (let i = 0; i < 150 && !switched; i++) {
|
||||
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch { /* reload replaced the page */ }
|
||||
@@ -178,12 +179,12 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, #chat-share-form, #chat-clear')`), false, 'unmapped session and sharing controls are unavailable');
|
||||
await b.evaluate(`(()=>{const el=document.querySelector('#chat-body');el.value='unsent alpha';el.dispatchEvent(new Event('input',{bubbles:true}))})()`);
|
||||
await b.evaluate(`location.hash='#/settings/business'`);
|
||||
await wait('!!document.querySelector("#org-picker")');
|
||||
await b.evaluate(`(()=>{const el=document.querySelector('#org-picker');el.value='beta';el.dispatchEvent(new Event('change',{bubbles:true}))})()`);
|
||||
await wait('!!document.querySelector("[data-workspace-org=beta] a")');
|
||||
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
|
||||
await wait('!!document.querySelector("dialog[open] [data-stay]")');
|
||||
await b.evaluate(`document.querySelector('[data-stay]').click()`);
|
||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\//);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
|
||||
assert.equal(await b.evaluate(`window.MosaicOrg.id`), 'alpha');
|
||||
await b.evaluate(`location.hash='#/chat/threads/${thread}'`);
|
||||
await wait('!!document.querySelector("#chat-body")');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`), 'unsent alpha');
|
||||
@@ -210,8 +211,16 @@ test('organization switch isolates browser board preferences', async t => {
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
await b.navigate(base + '#/orgs/alpha/');
|
||||
await b.evaluate(`document.querySelector('#show-machine').click()`);
|
||||
await b.navigate(base + '#/orgs/beta/');
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(window.MosaicOrg?.id === 'beta')return;await new Promise(r=>setTimeout(r,30))}throw Error('beta did not load')})()`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
|
||||
// The switch reloads the page. The navigate helper's load checks can be in flight when that
|
||||
// reload commits, so set the hash in the page and poll one short check at a time.
|
||||
await b.evaluate(`location.hash = '#/orgs/beta/'`);
|
||||
let loaded = false;
|
||||
for (let i = 0; i < 150 && !loaded; i++) {
|
||||
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
|
||||
if (!loaded) await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
assert.equal(loaded, true, 'beta did not load');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped.
|
||||
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
||||
];
|
||||
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
|
||||
const INSTANCES = { ddigital: { chief: ['chief-of-staff', ['r1']], copywriter: ['copywriter', ['r2']], 'montage-producer': ['producer', ['r3', 'r4']], editor: ['editor', []] }, forge: { chief: ['chief-of-staff', []] } };
|
||||
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'V-112 Launch email, draft 2', project: 'spring-launch', instance: 'copywriter' }, { id: 't2', name: 'V-118 Teaser reel', project: 'spring-launch', instance: 'montage-producer' }, { id: 't3', name: 'Weekly plan', project: 'brand-refresh', instance: 'chief' }], forge: [] };
|
||||
// The bus's derived state per held role: chief waits on the human, copywriter works, montage-producer
|
||||
// reports none, so its activity is not known and it shows only as running.
|
||||
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
|
||||
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
|
||||
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
|
||||
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
|
||||
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
|
||||
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
|
||||
return {
|
||||
list: async () => ORGS,
|
||||
resolve: async id => {
|
||||
seen?.push(id);
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
|
||||
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
|
||||
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
// No bus or setup work runs in these tests: every restart below is refused before the admin is used.
|
||||
const refusingAdmin = () => createOrgAdmin({ service: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
const SETTINGS = ['Identity', 'Appearance', 'System types', 'Projects', 'Agents and roles', 'Models and routing', 'Arbiters', 'Task tracker', 'Channels', 'Launch limits', 'Assistant', 'Token use', 'Model proxy', 'Console', 'Organizations', 'About', 'Members and invites', 'Bus', 'Archive or delete'];
|
||||
const TAGS = ['org file', 'override', 'system', 'root', 'not built', 'danger'];
|
||||
// Every page the Settings view has for the owner (bus.js settingsPages).
|
||||
const SETTINGS_PAGES = ['appearance', 'business', 'limits', 'organizations', 'routing', 'agents', 'assistant', 'tokens', 'model-proxy', 'console', 'about'];
|
||||
|
||||
test('workspace navigation exposes all organizations and remembers project grouping', async t => {
|
||||
const organizations = {
|
||||
list: async () => [{ id:'alpha', label:'Alpha', theme:null, busHealthy:true }, { id:'beta', label:'Beta', theme:'cobalt', busHealthy:false }],
|
||||
resolve: async id => ({ busHealthy:id === 'alpha', reads:{ business:async()=>({ business:{ id, projects:[] } }) }, bus:{}, chat:{projects:async()=>[],threads:async()=>[]} }),
|
||||
};
|
||||
const server = await startServer({port:0, sso:createDevOwner(), organizations, log:()=>{}}); t.after(()=>close(server));
|
||||
const b=await browser({machine:false}); t.after(()=>b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`);
|
||||
await wait('document.querySelectorAll("[data-workspace-org]").length === 2');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-workspace-org=beta]").textContent'), /Beta.*bus unavailable/is);
|
||||
// An organization's colour is its theme's action colour; no theme is the harbor default.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-workspace-org=alpha] .ws-av").style.getPropertyValue("--org-l")'), '#2266bf');
|
||||
await b.evaluate('document.querySelector("[data-org-toggle]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-list]").hidden'),true);
|
||||
await b.evaluate('document.querySelector("[data-org-toggle]").click(); document.querySelector("[data-group=flat]").click()');
|
||||
// The Projects group collapses too, and each group's open state is remembered (sample 01).
|
||||
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
|
||||
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-projects-toggle]").getAttribute("aria-expanded"), document.activeElement?.dataset.focusKey]'), [true, 'false', 'projects-toggle']);
|
||||
await b.evaluate('location.reload()');
|
||||
// The reload repaints the sidebar in stages; wait for both lists before reading them.
|
||||
await wait('document.querySelector("[data-group=flat]")?.getAttribute("aria-pressed") === "true" && !!document.querySelector("#ws-project-list") && !!document.querySelector("[data-org-list]")');
|
||||
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-org-list]").hidden]'), [true, false]);
|
||||
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#ws-project-list").hidden'), false);
|
||||
await b.viewport(390,844);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||||
});
|
||||
|
||||
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'),
|
||||
[['ddigital', true, 's-working'], ['forge', true, 's-working'], ['home', true, 's-offline']]);
|
||||
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").getAttribute("aria-pressed")'), 'true');
|
||||
// The four-tile logo stays.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".wordmark .mosaic-logo rect").length'), 4);
|
||||
// No organization switcher remains anywhere on the page.
|
||||
assert.equal(await b.evaluate('document.querySelector("#org-picker, select[name=org], [data-org-switch]")'), null);
|
||||
// Projects has no conversation tree under it; the Projects group links to each project page.
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections #project-tree, #sections .project-tree-list")'), null);
|
||||
assert.match(await b.evaluate('document.querySelector("#workspace-nav .ws-proj").getAttribute("href")'), /^#\/orgs\/[a-z0-9-]+\/projects\/[^/]+$/);
|
||||
// Queue and Docs are not sidebar items.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
|
||||
// Settings stays pinned to the foot of the sidebar.
|
||||
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
|
||||
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
|
||||
assert.equal(queue, '#/queue');
|
||||
await b.evaluate('document.querySelector("[data-org-queue]").click()');
|
||||
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
|
||||
// Views not built yet open and say so.
|
||||
for (const [route, title] of [['inbox', 'Inbox'], ['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
|
||||
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
|
||||
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
|
||||
}
|
||||
});
|
||||
|
||||
test('organization dashboard renders every section from the per-organization reads', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0');
|
||||
const head = await b.evaluate(`(() => { const h = document.querySelector('[data-org-dashboard] .ws-orghead'); return { avatar: h.querySelector('.ws-av')?.textContent, title: h.querySelector('h1').textContent, meta: h.querySelector('.ws-meta').innerText, colour: h.style.getPropertyValue('--org-l') }; })()`);
|
||||
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
|
||||
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
|
||||
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
|
||||
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/i);
|
||||
// Docs come from the project files reads: only documents, named by project.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
|
||||
// KPIs and metrics: one honest empty tile each, with no invented number.
|
||||
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
|
||||
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
|
||||
// Active agent sessions: the running instances, the thread their live run holds (chief's older
|
||||
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
|
||||
// reported state (montage-producer) the row says only running.
|
||||
const rows = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => [...tr.cells].map(c => c.innerText.replace(/\\s+/g, " ").trim()))');
|
||||
assert.deepEqual(rows, [
|
||||
['chief', 'Brand refresh Weekly plan', 'sonnet-5.5 Change', '◐ waiting on you'],
|
||||
['copywriter', 'Spring launch V-112 Launch email, draft 2', 'sonnet-5.5 Change', '● working'],
|
||||
['montage-producer', 'Spring launch V-118 Teaser reel', 'sonnet-5.5 Change', '● running 2 sessions'],
|
||||
]);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
|
||||
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
|
||||
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
|
||||
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
|
||||
assert.deepEqual(settings.map(s => s.name), SETTINGS);
|
||||
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
|
||||
const by = Object.fromEntries(settings.map(s => [s.name, s]));
|
||||
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
|
||||
assert.deepEqual([by['System types'].tag, by['System types'].href], ['not built', null]);
|
||||
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
|
||||
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
|
||||
assert.equal(by['Task tracker'].value, 'Vikunja');
|
||||
assert.equal(by['Launch limits'].value, '4 sessions at once');
|
||||
assert.equal(by['Agents and roles'].value, '4 agents · 3 running');
|
||||
for (const page of SETTINGS_PAGES) assert.ok(settings.some(s => s.href === `#/settings/${page}`), `no row opens settings/${page}`);
|
||||
});
|
||||
|
||||
test('organization dashboard empty states say so in one short line', async t => {
|
||||
const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }], resolve: async () => ({ busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => ({ business: 'alpha', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [] },
|
||||
chat: { projects: async () => [], threads: async () => [] } }) };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
|
||||
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
|
||||
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
|
||||
for (const line of [/System types\s+No system types yet\. They arrive with the system types update\./i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
assert.match(text, line);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
|
||||
});
|
||||
|
||||
test('organization dashboard presents sections and honest unavailable controls', async t => {
|
||||
const organizations = { list:async()=>[{id:'alpha',label:'Alpha',busHealthy:false}], resolve:async()=>({busHealthy:false,reads:{business:async()=>({business:{id:'alpha',projects:[]}})},bus:{},chat:{projects:async()=>[],threads:async()=>[]}}) };
|
||||
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}}); t.after(()=>close(server));
|
||||
const b=await browser({machine:false}); t.after(()=>b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
|
||||
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
|
||||
assert.match(text,/reload organization file/i);
|
||||
assert.match(text,/no metrics recorded yet/i);
|
||||
assert.match(text,/bus unavailable, read \d/);
|
||||
assert.match(text,/Sessions unavailable/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
|
||||
});
|
||||
|
||||
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
|
||||
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
|
||||
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
|
||||
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
|
||||
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
|
||||
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
|
||||
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
|
||||
})()`));
|
||||
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
|
||||
// The owner on a Console started with --send gets console-restart.js's restart button.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
|
||||
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
|
||||
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
|
||||
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
|
||||
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
|
||||
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
|
||||
// shows no controls at all.
|
||||
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-unavailable]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
|
||||
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
|
||||
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
|
||||
// There is no reload route.
|
||||
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
|
||||
});
|
||||
|
||||
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
|
||||
async function tabStops(b, start) {
|
||||
await b.evaluate(`(${start}).focus()`);
|
||||
const stops = [];
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const at = await b.evaluate(`(() => { const a = document.activeElement, sec = a.closest('[data-org-dashboard] section'); return { i: [...document.querySelectorAll('*')].indexOf(a), region: a.closest('#console-nav') ? 'nav' : sec ? sec.getAttribute('aria-label') : a.closest('[data-org-dashboard]') ? 'head' : 'other', key: a.dataset.focusKey || a.getAttribute('href') || a.textContent.trim() }; })()`);
|
||||
if (stops.length && at.region === 'other' && stops.at(-1).region !== 'other') break;
|
||||
stops.push(at);
|
||||
await b.key('Tab');
|
||||
}
|
||||
const boxes = await b.evaluate(`(${JSON.stringify(stops.map(s => s.i))}).map(i => { const r = document.querySelectorAll('*')[i].getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left }; })`);
|
||||
return stops.map((s, n) => ({ ...s, ...boxes[n] }));
|
||||
}
|
||||
function assertVisualOrder(stops, regions, label) {
|
||||
for (let n = 1; n < stops.length; n++) {
|
||||
const a = stops[n - 1], z = stops[n], where = `${label}: ${a.key} then ${z.key}`;
|
||||
assert.ok(z.i > a.i, `tab order leaves DOM order at ${where}`);
|
||||
assert.ok(regions.indexOf(z.region) >= regions.indexOf(a.region), `region order at ${where}`);
|
||||
if (z.region !== a.region) continue;
|
||||
const sameRow = z.top < a.bottom && z.bottom > a.top;
|
||||
assert.ok(sameRow ? z.left > a.left : z.top >= a.top, `visual order at ${where}`);
|
||||
}
|
||||
}
|
||||
|
||||
test('keyboard focus follows visual order and survives a data refresh', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0 && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
|
||||
const wide = await tabStops(b, 'document.querySelector("#sections a")');
|
||||
const regions = ['nav', 'head', 'Measures and sessions', 'Settings'];
|
||||
assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions);
|
||||
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
|
||||
const keys = wide.map(s => s.key);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
// The disabled reload is not a tab stop; the restart button is.
|
||||
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
|
||||
await b.viewport(390, 844);
|
||||
await wait('document.querySelector("[data-org-dashboard]")');
|
||||
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');
|
||||
assertVisualOrder(narrow, regions.slice(1), '390');
|
||||
for (let n = 1; n < narrow.length; n++) assert.ok(narrow[n].top >= narrow[n - 1].top - 1 || narrow[n].region === narrow[n - 1].region, `stacked order at 390: ${narrow[n - 1].key} then ${narrow[n].key}`);
|
||||
await b.viewport(1440, 900);
|
||||
|
||||
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
|
||||
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
|
||||
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
|
||||
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
|
||||
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), key);
|
||||
}
|
||||
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-bus-restart]').focus(); document.getElementById('refresh').click()`);
|
||||
await wait('document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
|
||||
assert.equal(await b.evaluate('document.activeElement.hasAttribute("data-bus-restart")'), true);
|
||||
await b.evaluate(`window.oldRow = document.querySelector('[data-workspace-org=forge]'); document.querySelector('[data-focus-key="org:forge"]').focus(); window.MosaicOrg.refreshList()`);
|
||||
await wait('document.querySelector("[data-workspace-org=forge]") !== window.oldRow');
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), 'org:forge');
|
||||
});
|
||||
|
||||
test('phone drawer opens over the dashboard with no horizontal scroll', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(390, 844);
|
||||
for (const route of ['organization', 'agents']) {
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/${route}`);
|
||||
await wait(route === 'organization' ? 'document.querySelector("[data-org-dashboard] [data-org-settings]")' : 'document.querySelector("[data-org-agents] .ws-levels")');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, route);
|
||||
}
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility === "hidden" || document.getElementById("console-nav").getBoundingClientRect().right <= 0'), true);
|
||||
await b.evaluate('document.getElementById("nav-open").click()');
|
||||
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
|
||||
await wait('document.querySelectorAll("[data-workspace-org]").length === 3');
|
||||
const drawer = await b.evaluate('(() => { const r = document.getElementById("console-nav").getBoundingClientRect(); return { left: r.left, right: r.right, sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }; })()');
|
||||
assert.ok(drawer.left >= 0 && drawer.right <= 390 && drawer.right > 200, JSON.stringify(drawer));
|
||||
assert.ok(drawer.sw <= drawer.cw, JSON.stringify(drawer));
|
||||
// The open drawer itself does not scroll sideways; the only clipped children are screen-reader text.
|
||||
assert.deepEqual(await b.evaluate('(() => { const nav = document.getElementById("console-nav"); return [nav.scrollWidth <= nav.clientWidth, [...nav.querySelectorAll("*")].filter(e => e.scrollWidth > e.clientWidth + 1 && getComputedStyle(e).overflowX !== "visible" && !e.classList.contains("ws-sr")).map(e => e.className)]; })()'), [true, []]);
|
||||
});
|
||||
|
||||
test('agents view includes organization-scoped instances without inventing root or project agents', async t => {
|
||||
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:{messages:async()=>({messages:[],unread:0}),agents:async()=>[],instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})},chat:{threads:async()=>[],projects:async()=>[]}})};
|
||||
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
|
||||
const b=await browser({machine:false});t.after(()=>b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/agents`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
|
||||
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
|
||||
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/);
|
||||
assert.match(text,/Root agents are not available yet/); assert.match(text,/Project agents are not available yet/);
|
||||
});
|
||||
|
||||
test('an unavailable selected bus does not hide other organizations on Agents', async t => {
|
||||
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:id==='alpha'?{agents:async()=>[],messages:async()=>({messages:[],unread:0}),instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})}:{},chat:{threads:async()=>[],projects:async()=>[]}})};
|
||||
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
|
||||
const b=await browser({machine:false});t.after(()=>b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/beta/agents`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
|
||||
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
|
||||
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents unavailable/);
|
||||
});
|
||||
|
||||
test('a failed conversation read says unavailable, never that none exists', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ failThreads: true }), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await waitFor(b)('document.querySelectorAll("[data-org-dashboard] .ws-tbl tbody tr").length === 3');
|
||||
const where = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => tr.cells[1].innerText.trim())');
|
||||
assert.deepEqual(where, ['Conversation unavailable', 'Conversation unavailable', 'Conversation unavailable']);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /No conversation/);
|
||||
});
|
||||
|
||||
test('an organization missing from the list, or an unreadable list, is never read', async t => {
|
||||
const seen = [];
|
||||
const orgs = workspaceOrgs({ seen });
|
||||
for (const [label, organizations, message] of [['not listed', orgs, /not on your list/], ['list fails', { ...orgs, list: async () => { throw new Error('list down'); } }, /could not be read/]]) {
|
||||
seen.length = 0;
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const target = label === 'not listed' ? 'denied' : 'ddigital';
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/${target}/organization`);
|
||||
await waitFor(b)('document.querySelector("[data-org-unavailable]")');
|
||||
const view = await b.evaluate('document.querySelector("[data-org-unavailable]").innerText');
|
||||
assert.match(view, /Organization unavailable/, label); assert.match(view, message, label);
|
||||
// None of the dashboard's per-organization reads went out for the target.
|
||||
const asked = await b.evaluate(`performance.getEntriesByType('resource').map(e => new URL(e.name).pathname).filter(p => p.startsWith('/api/orgs/${target}/'))`);
|
||||
// The app shell's own boot (app.js, unchanged here) reads /business once; the dashboard adds none.
|
||||
const dashboardReads = /\/(settings|queue|chat\/threads|chat\/projects|bus\/instances|bus\/agents|bus\/thread\.claims)$/;
|
||||
assert.deepEqual(asked.filter(p => dashboardReads.test(p)), [], `${label}: ${asked.join(' ')}`);
|
||||
assert.ok(asked.filter(p => p.endsWith('/business')).length <= 1, `${label}: ${asked.join(' ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('agents grid shows the reported state and a model Change link', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
|
||||
await waitFor(b)('document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr").length === 4');
|
||||
const rows = await b.evaluate('[...document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr")].map(tr => [tr.cells[0].innerText.split("\\n")[0].trim(), tr.cells[3].innerText.replace(/\\s+/g, " ").trim(), tr.cells[4].innerText.replace(/\\s+/g, " ").trim()])');
|
||||
assert.deepEqual(rows, [['chief', '◐ waiting on you', 'sonnet-5.5 Change'], ['copywriter', '● working', 'sonnet-5.5 Change'], ['montage-producer', '● running', 'sonnet-5.5 Change'], ['editor', '– not running', 'sonnet-5.5 Change']]);
|
||||
const pills = await b.evaluate('[...document.querySelectorAll("[data-org=ddigital] .ws-agent")].map(p => p.querySelector(".ws-dot").title)');
|
||||
assert.deepEqual(pills, ['waiting on you', 'working', 'running', 'not running']);
|
||||
// Keyboard reaches the Change links.
|
||||
const label = await b.evaluate('(() => { const a = document.querySelector("[data-org-agents] a.ws-change"); a.focus(); return document.activeElement === a && a.getAttribute("aria-label"); })()');
|
||||
assert.equal(label, 'Change the model for chief');
|
||||
});
|
||||
Reference in new issue
Block a user