feat(tasks): the Vikunja v2 adapter, broker task verbs and sync (row 38, S3, darkwing)
packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).
Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).
Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { client, apiRoot } from './vikunja.mjs';
|
||||
import { startup } from './startup.mjs';
|
||||
import { tick, reconcile } from './sync.mjs';
|
||||
import { verbs } from './verbs.mjs';
|
||||
// S3's adapter for startBroker({tasks}). `trackers` is the host's plain-data boot config:
|
||||
// {<business>: {baseUrl, project, pollSeconds, reconcileMinutes}}. A business with no entry has
|
||||
// no tracker, and its task verbs refuse. Startup runs in the background so the broker boots
|
||||
// without waiting on Vikunja; verbs refuse with tracker-starting until it passes.
|
||||
export const TIMEOUT = 60000;
|
||||
// Refusals that a later startup attempt can clear. A scope or install refusal waits for a restart.
|
||||
const TRANSIENT = new Set(['tracker-unavailable', 'tracker-rate-limited', 'service-failed']);
|
||||
const code = (e) => (e instanceof BusError ? e.code : 'adapter-failed');
|
||||
const positive = (x) => Number.isSafeInteger(x) && x > 0;
|
||||
function settings(name, t, b) {
|
||||
if (!b || !t || typeof t !== 'object') throw new BusError('tracker-config');
|
||||
apiRoot(t.baseUrl);
|
||||
const pollSeconds = t.pollSeconds ?? 30,
|
||||
reconcileMinutes = t.reconcileMinutes ?? 60;
|
||||
if (!positive(t.project) || !Number.isSafeInteger(pollSeconds) || pollSeconds < 10 || !positive(reconcileMinutes))
|
||||
throw new BusError('tracker-config');
|
||||
if (!positive(b.tracker?.sync?.botId)) throw new BusError('tracker-config');
|
||||
const roles = {};
|
||||
for (const [role, r] of Object.entries(b.roles ?? {}))
|
||||
if (r.tracker) {
|
||||
if (!positive(r.tracker.botId) || typeof r.definition !== 'string') throw new BusError('tracker-config');
|
||||
roles[role] = { definition: r.definition, botId: r.tracker.botId };
|
||||
}
|
||||
const pms = Object.keys(roles).filter((r) => roles[r].definition === 'pm');
|
||||
if (pms.length !== 1) throw new BusError('tracker-config');
|
||||
const labels = new Map();
|
||||
for (const [title, id] of Object.entries(b.tracker.labels ?? {})) {
|
||||
if (!positive(id)) throw new BusError('tracker-config');
|
||||
labels.set(id, title);
|
||||
}
|
||||
return { business: name, baseUrl: t.baseUrl, project: t.project, pollSeconds, reconcileMinutes, roles, pm: pms[0], labels };
|
||||
}
|
||||
// Files behind the business's Vikunja references, to notice a token rotated on disk.
|
||||
function files(b) {
|
||||
const out = {};
|
||||
for (const [role, r] of Object.entries(b.roles ?? {})) if (r.credentials?.vikunja?.file) out[role] = r.credentials.vikunja.file;
|
||||
if (b.tracker?.sync?.credentials?.vikunja?.file) out['@sync'] = b.tracker.sync.credentials.vikunja.file;
|
||||
return out;
|
||||
}
|
||||
const stamp = (file) => {
|
||||
try {
|
||||
const s = lstatSync(file);
|
||||
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
};
|
||||
export function tasksAdapter({
|
||||
trackers = {},
|
||||
fetch = globalThis.fetch,
|
||||
clock = Date.now,
|
||||
timers = globalThis,
|
||||
log = (line) => process.stderr.write(line + '\n'),
|
||||
autostart = true,
|
||||
} = {}) {
|
||||
return async function factory({ broker, credentials, businesses }) {
|
||||
const contexts = new Map();
|
||||
for (const [name, t] of Object.entries(trackers)) {
|
||||
const s = settings(name, t, businesses[name]);
|
||||
const call = client({ baseUrl: s.baseUrl, fetch });
|
||||
const use = (instance) => (method, path, opts) =>
|
||||
credentials.use(`${name}/${instance}`, 'vikunja', (token) => call(token, method, path, opts));
|
||||
const ctx = {
|
||||
...s,
|
||||
broker,
|
||||
credentials,
|
||||
clock,
|
||||
call,
|
||||
per: 50,
|
||||
view: null,
|
||||
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
|
||||
sync: use('@sync'),
|
||||
as: (role, method, path, opts) => use(role)(method, path, opts),
|
||||
state: 'starting',
|
||||
refused: null,
|
||||
queue: Promise.resolve(),
|
||||
pending: 0,
|
||||
lastStart: null,
|
||||
lastReconcile: null,
|
||||
lastComment: new Map(),
|
||||
commentsAt: new Map(),
|
||||
commentCount: new Map(),
|
||||
files: Object.fromEntries(Object.entries(files(businesses[name])).map(([i, f]) => [i, { file: f, stamp: stamp(f) }])),
|
||||
told: new Set(),
|
||||
timers: [],
|
||||
};
|
||||
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
|
||||
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts);
|
||||
ctx.handle = verbs(ctx);
|
||||
contexts.set(name, ctx);
|
||||
}
|
||||
let closed = false;
|
||||
// One operation at a time per business: a tick never reads mid-write.
|
||||
const serial = (ctx, fn) => {
|
||||
const run = ctx.queue.then(() => (closed ? Promise.reject(new BusError('tracker-closed')) : fn()));
|
||||
ctx.queue = run.catch(() => {});
|
||||
return run;
|
||||
};
|
||||
const background = (ctx, what, fn) => {
|
||||
if (ctx.pending > 1) return Promise.resolve();
|
||||
ctx.pending++;
|
||||
return serial(ctx, fn)
|
||||
.catch((e) => {
|
||||
if (!closed) log(`tasks ${ctx.business} ${what} ${code(e)}`);
|
||||
})
|
||||
.finally(() => ctx.pending--);
|
||||
};
|
||||
// credential.expiring, .expired and .changed, each once per instance per process.
|
||||
function notify(ctx) {
|
||||
const events = [];
|
||||
const tell = (kind, instance, date) => {
|
||||
const key = kind + ' ' + instance;
|
||||
if (ctx.told.has(key)) return;
|
||||
ctx.told.add(key);
|
||||
events.push({ kind, body: { service: 'vikunja', instance, ...(date ? { date } : {}) } });
|
||||
};
|
||||
for (const s of credentials.status())
|
||||
if (s.service === 'vikunja' && s.instance.startsWith(ctx.business + '/') && s.state !== 'valid')
|
||||
tell(`credential.${s.state}`, s.instance, s.date);
|
||||
for (const [instance, f] of Object.entries(ctx.files))
|
||||
if (stamp(f.file) !== f.stamp) tell('credential.changed', `${ctx.business}/${instance}`, null);
|
||||
if (events.length) broker.recordTask({ business: ctx.business, events });
|
||||
}
|
||||
async function start(ctx) {
|
||||
try {
|
||||
await startup(ctx);
|
||||
await reconcile(ctx);
|
||||
ctx.state = 'ready';
|
||||
ctx.refused = null;
|
||||
if (ctx.untested) log(`tasks ${ctx.business} startup untested-version`);
|
||||
} catch (e) {
|
||||
ctx.state = 'refused';
|
||||
ctx.refused = code(e);
|
||||
log(`tasks ${ctx.business} startup ${ctx.refused}`);
|
||||
}
|
||||
try {
|
||||
notify(ctx);
|
||||
} catch (e) {
|
||||
log(`tasks ${ctx.business} credentials ${code(e)}`);
|
||||
}
|
||||
}
|
||||
const api = {
|
||||
timeout: TIMEOUT,
|
||||
ready: null,
|
||||
async handle(cap, verb, args) {
|
||||
const { business } = broker.identity(cap);
|
||||
const ctx = contexts.get(business);
|
||||
if (!ctx) throw new BusError('tracker-unconfigured');
|
||||
if (ctx.state !== 'ready') throw new BusError(ctx.state === 'starting' ? 'tracker-starting' : ctx.refused);
|
||||
return serial(ctx, () => ctx.handle(cap, verb, args));
|
||||
},
|
||||
// For tests and the host's status verb. Each returns once the operation has run.
|
||||
start: (business) => serial(contexts.get(business), () => start(contexts.get(business))),
|
||||
tick: (business) => serial(contexts.get(business), () => tick(contexts.get(business))),
|
||||
reconcile: (business) => serial(contexts.get(business), () => reconcile(contexts.get(business))),
|
||||
notify: (business) => serial(contexts.get(business), async () => notify(contexts.get(business))),
|
||||
status: () =>
|
||||
[...contexts.values()].map((c) => ({
|
||||
business: c.business,
|
||||
state: c.state,
|
||||
refused: c.refused,
|
||||
version: c.version ?? null,
|
||||
untested: c.untested ?? null,
|
||||
lastPoll: c.lastStart === null ? null : new Date(c.lastStart).toISOString(),
|
||||
lastReconcile: c.lastReconcile === null ? null : new Date(c.lastReconcile).toISOString(),
|
||||
})),
|
||||
async close() {
|
||||
closed = true;
|
||||
for (const c of contexts.values()) for (const t of c.timers) timers.clearInterval(t);
|
||||
await Promise.all([...contexts.values()].map((c) => c.queue));
|
||||
},
|
||||
};
|
||||
const every = (ctx, ms, fn) => {
|
||||
const t = timers.setInterval(fn, ms);
|
||||
t?.unref?.();
|
||||
ctx.timers.push(t);
|
||||
};
|
||||
if (autostart) {
|
||||
api.ready = Promise.all(
|
||||
[...contexts.values()].map((ctx) => {
|
||||
every(ctx, ctx.pollSeconds * 1000, () => {
|
||||
if (ctx.state === 'ready')
|
||||
background(ctx, 'tick', async () => {
|
||||
await tick(ctx);
|
||||
notify(ctx);
|
||||
});
|
||||
else if (ctx.state === 'refused' && TRANSIENT.has(ctx.refused)) background(ctx, 'startup', () => start(ctx));
|
||||
});
|
||||
every(ctx, ctx.reconcileMinutes * 60000, () => {
|
||||
if (ctx.state === 'ready') background(ctx, 'reconcile', () => reconcile(ctx));
|
||||
});
|
||||
return background(ctx, 'startup', () => start(ctx));
|
||||
}),
|
||||
);
|
||||
}
|
||||
return api;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { canonicalJson, sha256 } from '../../business/src/util.mjs';
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
// Vikunja writes this for an unset date.
|
||||
export const NULL_DATE = '0001-01-01T00:00:00Z';
|
||||
const positive = (x) => Number.isSafeInteger(x) && x > 0;
|
||||
const ids = (list, where) => {
|
||||
if (list !== null && list !== undefined && !Array.isArray(list)) throw new BusError('tracker-shape');
|
||||
const out = (list ?? []).map((x) => x?.id);
|
||||
if (out.some((x) => !positive(x))) throw new BusError('tracker-shape');
|
||||
return out.sort((a, b) => a - b);
|
||||
};
|
||||
export const ref = (project, task) => `vikunja:${project}/${task}`;
|
||||
export function parseRef(taskRef) {
|
||||
const m = /^vikunja:([1-9][0-9]*)\/([1-9][0-9]*)$/.exec(taskRef ?? '');
|
||||
if (!m) throw new BusError('invalid-request');
|
||||
return { project: Number(m[1]), task: Number(m[2]) };
|
||||
}
|
||||
// Checks a task object from any v2 read and returns its updated time. Throws tracker-shape.
|
||||
export function checkTask(task) {
|
||||
if (
|
||||
!task ||
|
||||
typeof task !== 'object' ||
|
||||
!positive(task.id) ||
|
||||
!positive(task.project_id) ||
|
||||
typeof task.title !== 'string' ||
|
||||
typeof (task.description ?? '') !== 'string' ||
|
||||
typeof task.done !== 'boolean' ||
|
||||
!Number.isSafeInteger(task.priority) ||
|
||||
typeof task.percent_done !== 'number' ||
|
||||
typeof task.updated !== 'string' ||
|
||||
!Number.isFinite(Date.parse(task.updated))
|
||||
)
|
||||
throw new BusError('tracker-shape');
|
||||
return task.updated;
|
||||
}
|
||||
// The digest set from addendum B section 5. `bucket` comes from the board or a move, never the task.
|
||||
export function taskFields(task, bucket) {
|
||||
checkTask(task);
|
||||
if (!positive(bucket)) throw new BusError('tracker-shape');
|
||||
const due = task.due_date;
|
||||
if (due !== undefined && due !== null && (typeof due !== 'string' || !Number.isFinite(Date.parse(due))))
|
||||
throw new BusError('tracker-shape');
|
||||
return {
|
||||
project_id: task.project_id,
|
||||
title: task.title,
|
||||
description: task.description ?? '',
|
||||
done: task.done,
|
||||
// One text form, so an expected value written by S3 digests the same as the value read back.
|
||||
due_date: !due || due === NULL_DATE ? null : new Date(due).toISOString(),
|
||||
priority: task.priority,
|
||||
percent_done: task.percent_done,
|
||||
bucket,
|
||||
labels: ids(task.labels),
|
||||
assignees: ids(task.assignees),
|
||||
};
|
||||
}
|
||||
export const digest = (fields) => sha256(canonicalJson(fields));
|
||||
// A tombstone keeps only the reason, and the new project for a move.
|
||||
export const tombstone = (gone, project = null) => (project === null ? { gone } : { gone, project });
|
||||
@@ -0,0 +1,616 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { NULL_DATE } from './digest.mjs';
|
||||
// In-memory Vikunja v2.7.0 for tests. Statuses, error bodies and response shapes follow the
|
||||
// recordings in tests/fixtures/v2-shapes.json and the probe notes (probes.md, slice1-s3):
|
||||
// scope 401 before 404, bucket_id 0 outside the board and the move, `updated` reported to the
|
||||
// second but filtered on the stored value, moves between open buckets not bumping `updated`,
|
||||
// silent unassign, 403 on removing an absent label, and bot label visibility through the owner.
|
||||
const ROUTES = [
|
||||
['GET', /^\/info$/, null, 'info'],
|
||||
['GET', /^\/labels$/, ['labels', 'read_all'], 'labels'],
|
||||
['GET', /^\/projects\/(\d+)$/, ['projects', 'read_one'], 'project'],
|
||||
['GET', /^\/projects\/(\d+)\/views$/, ['projects_views', 'read_all'], 'views'],
|
||||
['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets$/, ['projects', 'views_buckets'], 'buckets'],
|
||||
['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/tasks$/, ['projects', 'views_buckets_tasks_get'], 'board'],
|
||||
['PUT', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/(\d+)\/tasks$/, ['projects', 'views_buckets_tasks'], 'move'],
|
||||
['GET', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'read_all'], 'list'],
|
||||
['POST', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'create'], 'create'],
|
||||
['GET', /^\/tasks\/(\d+)$/, ['tasks', 'read_one'], 'read'],
|
||||
['PATCH', /^\/tasks\/(\d+)$/, ['tasks', 'update'], 'patch'],
|
||||
['DELETE', /^\/tasks\/(\d+)$/, ['tasks', 'delete'], 'remove'],
|
||||
['GET', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'read_all'], 'comments'],
|
||||
['POST', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'create'], 'comment'],
|
||||
['POST', /^\/tasks\/(\d+)\/labels$/, ['tasks_labels', 'create'], 'labelAdd'],
|
||||
['DELETE', /^\/tasks\/(\d+)\/labels\/(\d+)$/, ['tasks_labels', 'delete'], 'labelRemove'],
|
||||
['POST', /^\/tasks\/(\d+)\/assignees$/, ['tasks_assignees', 'create'], 'assign'],
|
||||
['DELETE', /^\/tasks\/(\d+)\/assignees\/(\d+)$/, ['tasks_assignees', 'delete'], 'unassign'],
|
||||
['POST', /^\/tasks\/(\d+)\/relations$/, ['tasks_relations', 'create'], 'relate'],
|
||||
['DELETE', /^\/tasks\/(\d+)\/relations\/([a-z]+)\/(\d+)$/, ['tasks_relations', 'delete'], 'unrelate'],
|
||||
];
|
||||
const PATCH_KEYS = new Set([
|
||||
'title',
|
||||
'description',
|
||||
'done',
|
||||
'due_date',
|
||||
'priority',
|
||||
'percent_done',
|
||||
'bucket_id',
|
||||
'hex_color',
|
||||
'repeat_after',
|
||||
'start_date',
|
||||
'end_date',
|
||||
]);
|
||||
const CREATE_KEYS = new Set(['title', 'description', 'due_date', 'priority', 'percent_done']);
|
||||
const UNAUTHORIZED = { code: 11, message: 'missing, malformed, expired or otherwise invalid token provided' };
|
||||
const forbidden = (detail = 'Forbidden') => ({ title: 'Forbidden', status: 403, detail });
|
||||
const noTask = { title: 'Not Found', status: 404, detail: 'This task does not exist', code: 4002 };
|
||||
const noProject = { title: 'Not Found', status: 404, detail: 'The project does not exist.', code: 3001 };
|
||||
const bad = (detail, code) => ({ title: 'Bad Request', status: 400, detail, ...(code ? { code } : {}) });
|
||||
const unprocessable = (detail) => ({ title: 'Unprocessable Entity', status: 422, detail });
|
||||
const second = (ms) => new Date(Math.floor(ms / 1000) * 1000).toISOString().replace('.000Z', 'Z');
|
||||
// Vikunja v2.7.0 keeps a due date to the second; a write's answer echoes what was sent, in Go's form
|
||||
// (review r1, r1-vk-due-probe.txt). The probe can't tell floor from rounding; the adapter sends
|
||||
// whole seconds, so it doesn't matter to it.
|
||||
const dueIn = (x) => (x && x !== NULL_DATE ? second(Date.parse(x)) : null);
|
||||
const dueEcho = (x) => (x && x !== NULL_DATE ? new Date(x).toISOString().replace(/\.?0+Z$/, 'Z') : NULL_DATE);
|
||||
const fine = (ms) => new Date(ms).toISOString();
|
||||
const page = (list, q) => {
|
||||
const per = Math.min(Math.max(Number(q.get('per_page')) || 50, 1), 50),
|
||||
n = Math.max(Number(q.get('page')) || 1, 1);
|
||||
return {
|
||||
items: list.slice((n - 1) * per, n * per),
|
||||
total: list.length,
|
||||
page: n,
|
||||
per_page: per,
|
||||
total_pages: Math.max(1, Math.ceil(list.length / per)),
|
||||
};
|
||||
};
|
||||
|
||||
export class FakeVikunja {
|
||||
version;
|
||||
requests = [];
|
||||
#now;
|
||||
#seq = { user: 0, project: 0, view: 0, bucket: 0, task: 0, label: 0, comment: 0 };
|
||||
#users = new Map();
|
||||
#tokens = new Map();
|
||||
#projects = new Map();
|
||||
#views = new Map();
|
||||
#buckets = new Map();
|
||||
#tasks = new Map();
|
||||
#labels = new Map();
|
||||
#faults = [];
|
||||
constructor({ version = 'v2.7.0', now = () => Date.now() } = {}) {
|
||||
this.version = version;
|
||||
this.#now = now;
|
||||
}
|
||||
// Test setup. These act directly on the model, like an admin with database access.
|
||||
user(username, { owner = null } = {}) {
|
||||
const id = ++this.#seq.user;
|
||||
this.#users.set(id, { id, username, name: owner ? username.replace(/^bot-/, '') : '', bot: owner ?? 0, at: this.#now() });
|
||||
return id;
|
||||
}
|
||||
token(user, scopes = '*', { expires = null } = {}) {
|
||||
const t = 'tk_' + randomBytes(20).toString('hex');
|
||||
this.#tokens.set(t, { user, scopes, expires });
|
||||
return t;
|
||||
}
|
||||
revoke(token) {
|
||||
this.#tokens.delete(token);
|
||||
}
|
||||
project(title, owner) {
|
||||
const id = ++this.#seq.project;
|
||||
this.#projects.set(id, { id, title, owner, shares: new Map(), index: 0 });
|
||||
for (const [kind, position] of [
|
||||
['list', 100],
|
||||
['gantt', 200],
|
||||
['table', 300],
|
||||
])
|
||||
this.#view(id, kind[0].toUpperCase() + kind.slice(1), kind, position);
|
||||
const k = this.#view(id, 'Kanban', 'kanban', 400, 'manual');
|
||||
const [todo, , done] = ['To-Do', 'Doing', 'Done'].map((t) => this.bucket(k, t));
|
||||
Object.assign(this.#views.get(k), { default_bucket_id: todo, done_bucket_id: done });
|
||||
return id;
|
||||
}
|
||||
share(project, user, permission = 0) {
|
||||
this.#projects.get(project).shares.set(user, permission);
|
||||
}
|
||||
bucket(view, title) {
|
||||
const id = ++this.#seq.bucket;
|
||||
this.#buckets.set(id, { id, title, view, position: id * 100 });
|
||||
return id;
|
||||
}
|
||||
rename(bucket, title) {
|
||||
this.#buckets.get(bucket).title = title;
|
||||
}
|
||||
kanban(project) {
|
||||
return [...this.#views.values()].find((v) => v.project === project && v.view_kind === 'kanban');
|
||||
}
|
||||
view(id) {
|
||||
return this.#views.get(id);
|
||||
}
|
||||
addView(project, kind, mode = 'manual') {
|
||||
return this.#view(project, kind, kind, 500, mode);
|
||||
}
|
||||
// The runbook's install: rename the three defaults, add in-review and blocked.
|
||||
install(project) {
|
||||
const k = this.kanban(project);
|
||||
const ids = [...this.#buckets.values()].filter((b) => b.view === k.id).map((b) => b.id);
|
||||
['todo', 'in-progress', 'done'].forEach((t, i) => this.rename(ids[i], t));
|
||||
this.bucket(k.id, 'in-review');
|
||||
this.bucket(k.id, 'blocked');
|
||||
return { view: k.id, buckets: this.bucketIds(k.id) };
|
||||
}
|
||||
bucketIds(view) {
|
||||
return Object.fromEntries([...this.#buckets.values()].filter((b) => b.view === view).map((b) => [b.title, b.id]));
|
||||
}
|
||||
label(title, owner) {
|
||||
const id = ++this.#seq.label;
|
||||
this.#labels.set(id, { id, title, owner, at: this.#now() });
|
||||
return id;
|
||||
}
|
||||
task(id) {
|
||||
return this.#tasks.get(id);
|
||||
}
|
||||
moveToProject(task, project) {
|
||||
const t = this.#tasks.get(task);
|
||||
t.project = project;
|
||||
t.buckets = new Map();
|
||||
this.#place(t);
|
||||
this.#touch(t);
|
||||
}
|
||||
// The route name a request matched, for tests that compare the fake against the recordings.
|
||||
routeName(method, path) {
|
||||
return ROUTES.find(([m, re]) => m === method && re.test(path))?.[3] ?? null;
|
||||
}
|
||||
// fault(method, pattern, status, {apply}) fails the next matching request. Status 0 is a network
|
||||
// error. With apply the change lands first, as when a reply is lost after the commit.
|
||||
fault(method, pattern, status, { apply = false } = {}) {
|
||||
this.#faults.push({ method, pattern, status, apply });
|
||||
}
|
||||
fetch = async (url, init = {}) => {
|
||||
const u = new URL(url);
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const path = u.pathname.replace(/^\/api\/v2/, '');
|
||||
const i = this.#faults.findIndex((f) => f.method === method && f.pattern.test(path));
|
||||
const fault = i >= 0 ? this.#faults.splice(i, 1)[0] : null;
|
||||
if (fault && !fault.apply) {
|
||||
this.requests.push({ method, path, status: fault.status });
|
||||
if (fault.status === 0) throw new TypeError('fetch failed');
|
||||
return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status, detail: 'unexpected error occurred' }), { status: fault.status });
|
||||
}
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
let body;
|
||||
try {
|
||||
body = init.body ? JSON.parse(init.body) : undefined;
|
||||
} catch {
|
||||
body = Symbol.for('bad');
|
||||
}
|
||||
const r = u.pathname.startsWith('/api/v2/') ? this.#route(method, path, u.searchParams, headers, body) : { status: 404, body: { message: 'Not Found' } };
|
||||
if (fault) {
|
||||
this.requests.push({ method, path, status: fault.status });
|
||||
if (fault.status === 0) throw new TypeError('fetch failed');
|
||||
return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status }), { status: fault.status });
|
||||
}
|
||||
this.requests.push({ method, path, status: r.status });
|
||||
const h = { 'content-type': 'application/json', ...(r.etag ? { etag: r.etag } : {}) };
|
||||
return new Response([204, 304].includes(r.status) ? null : JSON.stringify(r.body), { status: r.status, headers: h });
|
||||
};
|
||||
// The same model over HTTP on 127.0.0.1, for code that should use the platform fetch.
|
||||
async listen() {
|
||||
const server = createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const r = await this.fetch('http://fake' + req.url, {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: chunks.length ? Buffer.concat(chunks).toString() : undefined,
|
||||
}).catch(() => null);
|
||||
if (!r) return res.destroy();
|
||||
res.writeHead(r.status, Object.fromEntries(r.headers));
|
||||
res.end(Buffer.from(await r.arrayBuffer()));
|
||||
});
|
||||
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
close: () => new Promise((ok) => server.close(ok)),
|
||||
};
|
||||
}
|
||||
#view(project, title, kind, position, mode = 'none') {
|
||||
const id = ++this.#seq.view;
|
||||
this.#views.set(id, {
|
||||
id,
|
||||
title,
|
||||
project,
|
||||
view_kind: kind,
|
||||
position,
|
||||
bucket_configuration_mode: mode,
|
||||
default_bucket_id: 0,
|
||||
done_bucket_id: 0,
|
||||
at: this.#now(),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
#access(user, project) {
|
||||
const p = this.#projects.get(project);
|
||||
if (!p) return null;
|
||||
if (p.owner === user) return 2;
|
||||
return p.shares.has(user) ? p.shares.get(user) : null;
|
||||
}
|
||||
#userJson(id) {
|
||||
const u = this.#users.get(id);
|
||||
if (!u) return null;
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
...(u.bot ? { bot_owner_id: u.bot } : {}),
|
||||
created: second(u.at),
|
||||
updated: second(u.at),
|
||||
};
|
||||
}
|
||||
#labelJson(l) {
|
||||
return { id: l.id, title: l.title, description: '', hex_color: '', created_by: this.#userJson(l.owner), created: second(l.at), updated: second(l.at) };
|
||||
}
|
||||
#visibleLabel(user, label) {
|
||||
const u = this.#users.get(user);
|
||||
if (label.owner === user) return true;
|
||||
// Upstream #3592: a bot reads labels its owner created and labels sibling bots created.
|
||||
if (u?.bot && (label.owner === u.bot || this.#users.get(label.owner)?.bot === u.bot)) return true;
|
||||
return [...this.#tasks.values()].some((t) => t.labels.includes(label.id) && this.#access(user, t.project) !== null);
|
||||
}
|
||||
#place(t) {
|
||||
for (const v of this.#views.values())
|
||||
if (v.project === t.project && v.view_kind === 'kanban')
|
||||
t.buckets.set(v.id, t.done && v.done_bucket_id ? v.done_bucket_id : v.default_bucket_id);
|
||||
}
|
||||
#touch(t) {
|
||||
t.updated = Math.max(this.#now(), t.updated + 1);
|
||||
t.rev++;
|
||||
}
|
||||
#taskJson(t, { bucket = 0, empty = null, expand = false } = {}) {
|
||||
const related = {};
|
||||
for (const r of t.relations) (related[r.kind] ??= []).push({ id: r.other });
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
project_id: t.project,
|
||||
done: t.done,
|
||||
done_at: t.doneAt ? fine(t.doneAt) : NULL_DATE,
|
||||
due_date: t.due ?? NULL_DATE,
|
||||
reminders: null,
|
||||
repeat_after: 0,
|
||||
repeat_mode: 0,
|
||||
priority: t.priority,
|
||||
start_date: NULL_DATE,
|
||||
end_date: NULL_DATE,
|
||||
assignees: t.assignees.length ? t.assignees.map((a) => this.#userJson(a)) : empty,
|
||||
labels: t.labels.length ? t.labels.map((l) => this.#labelJson(this.#labels.get(l))) : null,
|
||||
hex_color: '',
|
||||
percent_done: t.percent,
|
||||
identifier: `#${t.index}`,
|
||||
index: t.index,
|
||||
related_tasks: related,
|
||||
attachments: null,
|
||||
cover_image_attachment_id: 0,
|
||||
is_favorite: false,
|
||||
created: second(t.created),
|
||||
updated: second(t.updated),
|
||||
bucket_id: bucket,
|
||||
position: 0,
|
||||
reactions: null,
|
||||
created_by: this.#userJson(t.by),
|
||||
...(expand ? { comment_count: t.comments.length } : {}),
|
||||
};
|
||||
}
|
||||
#bucketJson(b, extra = {}) {
|
||||
return {
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
project_view_id: b.view,
|
||||
...extra,
|
||||
limit: 0,
|
||||
count: [...this.#tasks.values()].filter((t) => t.buckets.get(b.view) === b.id).length,
|
||||
position: b.position,
|
||||
created: second(this.#views.get(b.view).at),
|
||||
updated: second(this.#views.get(b.view).at),
|
||||
created_by: null,
|
||||
};
|
||||
}
|
||||
#filter(text) {
|
||||
if (!text) return () => true;
|
||||
const tests = text.split(/\s*&&\s*/).map((clause) => {
|
||||
const m = /^(updated|id|done)\s*(>=|<=|!=|>|<|=)\s*(.+)$/.exec(clause.trim());
|
||||
if (!m) return null;
|
||||
const [, field, op, raw] = m;
|
||||
let value;
|
||||
if (field === 'updated') {
|
||||
const v = raw.replace(/^['"]|['"]$/g, '');
|
||||
if (!/^\d{4}-\d\d-\d\dT/.test(v) || !Number.isFinite(Date.parse(v))) return null;
|
||||
value = Date.parse(v);
|
||||
} else if (field === 'id') value = Number(raw);
|
||||
else value = raw === 'true';
|
||||
const get = (t) => (field === 'updated' ? t.updated : field === 'id' ? t.id : t.done);
|
||||
return (t) =>
|
||||
({ '>': get(t) > value, '<': get(t) < value, '>=': get(t) >= value, '<=': get(t) <= value, '=': get(t) === value, '!=': get(t) !== value })[op];
|
||||
});
|
||||
if (tests.includes(null)) return null;
|
||||
return (t) => tests.every((f) => f(t));
|
||||
}
|
||||
#route(method, path, q, headers, body) {
|
||||
const hit = ROUTES.map(([m, re, scope, name]) => [m, re.exec(path), scope, name]).find(([m, x]) => m === method && x);
|
||||
if (!hit) return { status: 404, body: { message: 'Not Found' } };
|
||||
const [, match, scope, name] = hit;
|
||||
let user = null;
|
||||
if (scope) {
|
||||
const auth = /^Bearer (.+)$/.exec(headers.get('authorization') ?? '');
|
||||
const tok = auth && this.#tokens.get(auth[1]);
|
||||
if (!tok || (tok.expires !== null && tok.expires <= this.#now())) return { status: 401, body: UNAUTHORIZED };
|
||||
// The token check runs before the handler, so a missing scope is 401 even for a missing task.
|
||||
if (tok.scopes !== '*' && !tok.scopes[scope[0]]?.includes(scope[1])) return { status: 401, body: UNAUTHORIZED };
|
||||
user = tok.user;
|
||||
}
|
||||
if (body === Symbol.for('bad')) return { status: 400, body: bad('Invalid model provided.') };
|
||||
const args = match.slice(1).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
|
||||
return this[`_${name}`](user, args, q, body ?? {}, headers);
|
||||
}
|
||||
#taskFor(user, id, write = false) {
|
||||
const t = this.#tasks.get(id);
|
||||
if (!t) return { error: { status: 404, body: noTask } };
|
||||
const a = this.#access(user, t.project);
|
||||
if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden(a === null ? "You don't have the permission to see this" : 'Forbidden') } };
|
||||
return { t };
|
||||
}
|
||||
#projectFor(user, id, write = false) {
|
||||
if (!this.#projects.has(id)) return { error: { status: 404, body: noProject } };
|
||||
const a = this.#access(user, id);
|
||||
if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden("You don't have the permission to see this") } };
|
||||
return { p: this.#projects.get(id) };
|
||||
}
|
||||
_info() {
|
||||
return { status: 200, body: { version: this.version, max_items_per_page: 50, task_comments_enabled: true } };
|
||||
}
|
||||
_labels(user, _a, q) {
|
||||
const list = [...this.#labels.values()].filter((l) => this.#visibleLabel(user, l)).map((l) => this.#labelJson(l));
|
||||
return { status: 200, body: page(list, q) };
|
||||
}
|
||||
_project(user, [p]) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
return { status: 200, body: { id: p, title: r.p.title, description: '', owner: this.#userJson(r.p.owner), is_archived: false } };
|
||||
}
|
||||
_views(user, [p], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
const list = [...this.#views.values()]
|
||||
.filter((v) => v.project === p)
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
project_id: p,
|
||||
view_kind: v.view_kind,
|
||||
filter: null,
|
||||
position: v.position,
|
||||
bucket_configuration_mode: v.bucket_configuration_mode,
|
||||
bucket_configuration: null,
|
||||
default_bucket_id: v.default_bucket_id,
|
||||
done_bucket_id: v.done_bucket_id,
|
||||
updated: second(v.at),
|
||||
created: second(v.at),
|
||||
}));
|
||||
return { status: 200, body: page(list, q) };
|
||||
}
|
||||
_buckets(user, [p, k], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
if (this.#views.get(k)?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } };
|
||||
return { status: 200, body: page([...this.#buckets.values()].filter((b) => b.view === k).map((b) => this.#bucketJson(b)), q) };
|
||||
}
|
||||
_board(user, [p, k], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
const v = this.#views.get(k);
|
||||
if (v?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } };
|
||||
const f = q.get('filter') ?? '';
|
||||
if (f && f !== 'done = false') return { status: 400, body: bad('unsupported filter in the fake') };
|
||||
const per = Math.min(Number(q.get('per_page')) || 50, 50),
|
||||
n = Math.max(Number(q.get('page')) || 1, 1);
|
||||
const items = [...this.#buckets.values()]
|
||||
.filter((b) => b.view === k)
|
||||
.map((b) => {
|
||||
const all = [...this.#tasks.values()].filter((t) => t.buckets.get(k) === b.id && (!f || !t.done)).sort((a, b) => a.id - b.id);
|
||||
const tasks = all.slice((n - 1) * per, n * per).map((t) => this.#taskJson(t, { bucket: b.id }));
|
||||
return { ...this.#bucketJson(b, { tasks: tasks.length ? tasks : null }), count: all.length };
|
||||
});
|
||||
return { status: 200, body: { items } };
|
||||
}
|
||||
_move(user, [p, k, b], _q, body) {
|
||||
const r = this.#projectFor(user, p, true);
|
||||
if (r.error) return r.error;
|
||||
const v = this.#views.get(k),
|
||||
bucket = this.#buckets.get(b);
|
||||
if (v?.project !== p || bucket?.view !== k) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This bucket does not exist.', code: 10001 } };
|
||||
const x = this.#taskFor(user, body.task_id, true);
|
||||
if (x.error) return x.error;
|
||||
const t = x.t;
|
||||
if (t.project !== p) return { status: 400, body: bad('task is in another project') };
|
||||
const was = t.buckets.get(k);
|
||||
t.buckets.set(k, b);
|
||||
if (b === v.done_bucket_id && !t.done) {
|
||||
t.done = true;
|
||||
t.doneAt = this.#now();
|
||||
this.#touch(t);
|
||||
} else if (was === v.done_bucket_id && b !== was && t.done) {
|
||||
t.done = false;
|
||||
t.doneAt = null;
|
||||
this.#touch(t);
|
||||
} else t.rev++;
|
||||
return {
|
||||
status: 200,
|
||||
body: { bucket_id: b, bucket: this.#bucketJson(bucket), task_id: t.id, project_view_id: k, task: this.#taskJson(t) },
|
||||
};
|
||||
}
|
||||
_list(user, [p], q) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
const f = this.#filter(q.get('filter'));
|
||||
if (!f) return { status: 400, body: bad('Invalid filter expression', 1) };
|
||||
const sorts = q.getAll('sort_by'),
|
||||
orders = q.getAll('order_by');
|
||||
const list = [...this.#tasks.values()].filter((t) => t.project === p && f(t));
|
||||
list.sort((a, b) => {
|
||||
for (const [i, s] of (sorts.length ? sorts : ['id']).entries()) {
|
||||
const d = (s === 'updated' ? a.updated - b.updated : a.id - b.id) * (orders[i] === 'desc' ? -1 : 1);
|
||||
if (d) return d;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const expand = q.get('expand') === 'comment_count';
|
||||
const out = page(list, q);
|
||||
out.items = out.items.map((t) => this.#taskJson(t, { empty: null, expand }));
|
||||
return { status: 200, body: out };
|
||||
}
|
||||
_create(user, [p], _q, body) {
|
||||
const r = this.#projectFor(user, p, true);
|
||||
if (r.error) return r.error;
|
||||
if (Object.keys(body).some((k) => !CREATE_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') };
|
||||
if (typeof body.title !== 'string' || !body.title.trim()) return { status: 400, body: bad('The task title cannot be empty.', 4001) };
|
||||
const now = this.#now();
|
||||
const t = {
|
||||
id: ++this.#seq.task,
|
||||
project: p,
|
||||
index: ++r.p.index,
|
||||
title: body.title,
|
||||
description: body.description ?? '',
|
||||
done: false,
|
||||
doneAt: null,
|
||||
due: dueIn(body.due_date),
|
||||
priority: body.priority ?? 0,
|
||||
percent: body.percent_done ?? 0,
|
||||
labels: [],
|
||||
assignees: [],
|
||||
relations: [],
|
||||
comments: [],
|
||||
buckets: new Map(),
|
||||
created: now,
|
||||
updated: now,
|
||||
by: user,
|
||||
rev: 0,
|
||||
};
|
||||
this.#place(t);
|
||||
this.#tasks.set(t.id, t);
|
||||
const out = this.#taskJson(t, { empty: [] });
|
||||
out.related_tasks = null;
|
||||
if ('due_date' in body) out.due_date = dueEcho(body.due_date);
|
||||
out.created = fine(now);
|
||||
out.updated = fine(now);
|
||||
return { status: 201, body: out };
|
||||
}
|
||||
_read(user, [id], _q, _b, headers) {
|
||||
const x = this.#taskFor(user, id);
|
||||
if (x.error) return x.error;
|
||||
const etag = `"${x.t.id}-${x.t.rev}"`;
|
||||
if (headers.get('if-none-match') === etag) return { status: 304, etag };
|
||||
return { status: 200, body: this.#taskJson(x.t), etag };
|
||||
}
|
||||
_patch(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (Object.keys(body).some((k) => !PATCH_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') };
|
||||
const t = x.t,
|
||||
before = JSON.stringify(this.#taskJson(t));
|
||||
if ('title' in body) t.title = body.title;
|
||||
if ('description' in body) t.description = body.description;
|
||||
if ('due_date' in body) t.due = dueIn(body.due_date);
|
||||
if ('priority' in body) t.priority = body.priority;
|
||||
if ('percent_done' in body) t.percent = body.percent_done;
|
||||
if ('done' in body && body.done !== t.done) {
|
||||
t.done = body.done;
|
||||
t.doneAt = t.done ? this.#now() : null;
|
||||
for (const v of this.#views.values())
|
||||
if (v.project === t.project && v.view_kind === 'kanban')
|
||||
t.buckets.set(v.id, t.done ? v.done_bucket_id : v.default_bucket_id);
|
||||
}
|
||||
// bucket_id is echoed and not applied (probes.md, "Writes and moves").
|
||||
if (JSON.stringify(this.#taskJson(t)) === before && !('bucket_id' in body)) return { status: 304 };
|
||||
if (JSON.stringify(this.#taskJson(t)) !== before) this.#touch(t);
|
||||
const out = this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 });
|
||||
if ('due_date' in body) out.due_date = dueEcho(body.due_date);
|
||||
return { status: 200, body: out };
|
||||
}
|
||||
_remove(user, [id]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
this.#tasks.delete(id);
|
||||
return { status: 204 };
|
||||
}
|
||||
_comments(user, [id], q) {
|
||||
const x = this.#taskFor(user, id);
|
||||
if (x.error) return x.error;
|
||||
return { status: 200, body: page(x.t.comments.map((c) => ({ ...c, created: second(c.at), updated: second(c.at), at: undefined })), q) };
|
||||
}
|
||||
_comment(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (typeof body.comment !== 'string' || !body.comment) return { status: 400, body: bad('comment required') };
|
||||
const at = this.#now();
|
||||
const c = { id: ++this.#seq.comment, comment: body.comment, author: this.#userJson(user), reactions: null, at };
|
||||
x.t.comments.push(c);
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { id: c.id, comment: c.comment, author: c.author, reactions: null, created: fine(at), updated: fine(at) } };
|
||||
}
|
||||
_labelAdd(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
const l = this.#labels.get(body.label_id);
|
||||
if (!l || !this.#visibleLabel(user, l)) return { status: 403, body: forbidden() };
|
||||
if (x.t.labels.includes(l.id)) return { status: 400, body: bad('The label already exists on the task.', 8001) };
|
||||
x.t.labels.push(l.id);
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { label_id: l.id, created: fine(this.#now()) } };
|
||||
}
|
||||
_labelRemove(user, [id, label]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (!x.t.labels.includes(label)) return { status: 403, body: forbidden() };
|
||||
x.t.labels = x.t.labels.filter((l) => l !== label);
|
||||
this.#touch(x.t);
|
||||
return { status: 204 };
|
||||
}
|
||||
_assign(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (!this.#users.has(body.user_id)) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The user does not exist.', code: 1005 } };
|
||||
if (x.t.assignees.includes(body.user_id)) return { status: 400, body: bad('This user is already assigned to that task.', 4021) };
|
||||
x.t.assignees.push(body.user_id);
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { user_id: body.user_id, created: NULL_DATE } };
|
||||
}
|
||||
_unassign(user, [id, who]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (x.t.assignees.includes(who)) {
|
||||
x.t.assignees = x.t.assignees.filter((a) => a !== who);
|
||||
this.#touch(x.t);
|
||||
}
|
||||
return { status: 204 };
|
||||
}
|
||||
_relate(user, [id], _q, body) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
if (!this.#tasks.has(body.other_task_id)) return { status: 404, body: noTask };
|
||||
x.t.relations.push({ kind: body.relation_kind, other: body.other_task_id });
|
||||
this.#touch(x.t);
|
||||
return { status: 201, body: { task_id: id, other_task_id: body.other_task_id, relation_kind: body.relation_kind, created_by: this.#userJson(user), created: fine(this.#now()) } };
|
||||
}
|
||||
_unrelate(user, [id, kind, other]) {
|
||||
const x = this.#taskFor(user, id, true);
|
||||
if (x.error) return x.error;
|
||||
const i = x.t.relations.findIndex((r) => r.kind === kind && r.other === other);
|
||||
if (i < 0) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The task relation does not exist.', code: 4009 } };
|
||||
x.t.relations.splice(i, 1);
|
||||
this.#touch(x.t);
|
||||
return { status: 204 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { tasksAdapter, TIMEOUT } from './adapter.mjs';
|
||||
export { startup, TESTED_VERSION, BUCKETS, MISSING } from './startup.mjs';
|
||||
export { tick, reconcile, WINDOW_MS } from './sync.mjs';
|
||||
export { verbs } from './verbs.mjs';
|
||||
export { client, apiRoot, outcome, refusal } from './vikunja.mjs';
|
||||
export { ref, parseRef, taskFields, digest, tombstone } from './digest.mjs';
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { outcome, all } from './vikunja.mjs';
|
||||
// Startup checks from addendum B sections 2 and 3, run before the first poll. Any failure refuses
|
||||
// the business: the adapter then answers every task verb for it with the refusal code.
|
||||
export const TESTED_VERSION = 'v2.7.0';
|
||||
export const BUCKETS = ['todo', 'in-progress', 'in-review', 'blocked', 'done'];
|
||||
// The addendum used "one more than the highest task id the sync bot sees". On a shared instance
|
||||
// that id can belong to a project the bot can't read, which answers 403, not 404. Task ids are
|
||||
// int64, so the largest int32 is missing on any instance this stack will meet.
|
||||
export const MISSING = 2147483647;
|
||||
const refuse = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const expect = (r, status, code) => {
|
||||
if (r.status !== status) refuse(r.status === 401 ? 'tracker-unauthorized' : r.status === 0 || r.status >= 500 ? 'tracker-unavailable' : code);
|
||||
};
|
||||
export function parseVersion(v) {
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v ?? '');
|
||||
return m ? m.slice(1, 4).map(Number) : null;
|
||||
}
|
||||
export async function startup(ctx) {
|
||||
const info = await ctx.call(null, 'GET', '/info');
|
||||
expect(info, 200, 'tracker-unexpected');
|
||||
const v = parseVersion(info.json?.version);
|
||||
if (!v || v[0] !== 2 || v[1] < 4) refuse('tracker-version');
|
||||
const max = info.json?.max_items_per_page;
|
||||
ctx.per = Number.isSafeInteger(max) && max > 0 ? Math.min(50, max) : 50;
|
||||
ctx.version = info.json.version;
|
||||
ctx.untested = info.json.version !== TESTED_VERSION;
|
||||
// expires_at, checked locally: Vikunja accepts a mint with a past expiry (probes.md, O).
|
||||
const mine = ctx.credentials
|
||||
.status()
|
||||
.filter((s) => s.service === 'vikunja' && s.instance.startsWith(ctx.business + '/'));
|
||||
if (!mine.some((s) => s.instance === ctx.business + '/@sync')) refuse('credential-unavailable');
|
||||
if (mine.some((s) => s.state === 'expired')) refuse('credential-expired');
|
||||
// Sync identity: the control must pass and the write probe must be refused by scope.
|
||||
expect(await ctx.sync('GET', `/projects/${ctx.project}`), 200, 'tracker-project');
|
||||
await install(ctx);
|
||||
const probe = await ctx.sync('PATCH', `/tasks/${MISSING}`, { body: {} });
|
||||
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
|
||||
expect(probe, 401, 'tracker-unexpected');
|
||||
for (const [role, r] of Object.entries(ctx.roles)) {
|
||||
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`);
|
||||
if (control.status !== 404 || control.json?.code !== 4002) {
|
||||
expect(control, 404, 'tracker-unexpected');
|
||||
refuse('tracker-unexpected');
|
||||
}
|
||||
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
|
||||
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
|
||||
for (const [method, path, body] of probes) {
|
||||
const p = await ctx.as(role, method, path, { body });
|
||||
if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad');
|
||||
if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
|
||||
}
|
||||
}
|
||||
// Every configured label must be visible to the pm, or label writes would answer 403.
|
||||
if (ctx.labels.size) {
|
||||
const seen = await all(ctx.asCall(ctx.pm), null, '/labels', {}, ctx.per);
|
||||
if (!seen.ok) refuse(seen.r.status === 401 ? 'tracker-unauthorized' : 'tracker-unavailable');
|
||||
const ids = new Set(seen.items.map((l) => l?.id));
|
||||
if ([...ctx.labels.keys()].some((id) => !ids.has(id))) refuse('tracker-labels');
|
||||
}
|
||||
}
|
||||
// Install checks: one manual kanban view, the five buckets once each, done and default wired.
|
||||
async function install(ctx) {
|
||||
const views = await all(ctx.syncCall, null, `/projects/${ctx.project}/views`, {}, ctx.per);
|
||||
if (!views.ok) refuse('tracker-unavailable');
|
||||
const kanban = views.items.filter((x) => x?.view_kind === 'kanban');
|
||||
if (kanban.length !== 1 || kanban[0].bucket_configuration_mode !== 'manual') refuse('tracker-install');
|
||||
const k = kanban[0];
|
||||
const buckets = await all(ctx.syncCall, null, `/projects/${ctx.project}/views/${k.id}/buckets`, {}, ctx.per);
|
||||
if (!buckets.ok) refuse('tracker-unavailable');
|
||||
const ids = {};
|
||||
for (const title of BUCKETS) {
|
||||
const hits = buckets.items.filter((b) => b?.title === title);
|
||||
if (hits.length !== 1 || !Number.isSafeInteger(hits[0].id) || hits[0].id < 1) refuse('tracker-install');
|
||||
ids[title] = hits[0].id;
|
||||
}
|
||||
if (k.done_bucket_id !== ids.done || k.default_bucket_id !== ids.todo) refuse('tracker-install');
|
||||
ctx.view = {
|
||||
id: k.id,
|
||||
done: ids.done,
|
||||
todo: ids.todo,
|
||||
ids,
|
||||
titles: Object.fromEntries(Object.entries(ids).map(([t, id]) => [id, t])),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { outcome, refusal, all } from './vikunja.mjs';
|
||||
import { ref, checkTask, taskFields, digest, tombstone } from './digest.mjs';
|
||||
// The sync bot's reads (addendum B sections 3 and 5). A tick reads the whole open board, because a
|
||||
// move between open buckets doesn't bump `updated`, then the cursor: every task updated since the
|
||||
// previous tick's start minus WINDOW_MS. The window covers the async bump lag and the one-second
|
||||
// rounding (probes.md, U and Paging). Overlap is deduped by digest against task_current.
|
||||
export const WINDOW_MS = 60000;
|
||||
const iso = (ms) => new Date(ms).toISOString();
|
||||
const floorSecond = (ms) => iso(Math.floor(ms / 1000) * 1000);
|
||||
// `updated` comes back to the second, or with nanoseconds on a create. task_external_changes compares
|
||||
// it as text, so every snapshot stores one form.
|
||||
export const normal = (updated) => iso(Date.parse(updated));
|
||||
const shape = () => {
|
||||
throw new BusError('tracker-shape');
|
||||
};
|
||||
// The open board, every page. A bucket's tasks are null when it is empty; `count` is the bucket's total.
|
||||
export async function board(ctx) {
|
||||
const seen = new Map();
|
||||
for (let page = 1; page <= 1000; page++) {
|
||||
const r = await ctx.sync('GET', `/projects/${ctx.project}/views/${ctx.view.id}/buckets/tasks`, {
|
||||
query: { filter: 'done = false', per_page: ctx.per, page },
|
||||
});
|
||||
if (outcome(r) !== 'ok') throw new BusError(refusal(r));
|
||||
if (!Array.isArray(r.json?.items)) shape();
|
||||
let more = false;
|
||||
for (const b of r.json.items) {
|
||||
const tasks = b?.tasks ?? [];
|
||||
if (!Number.isSafeInteger(b?.id) || !Array.isArray(tasks)) shape();
|
||||
for (const t of tasks) {
|
||||
checkTask(t);
|
||||
seen.set(t.id, { task: t, bucket: b.id });
|
||||
}
|
||||
if (Number.isSafeInteger(b.count) ? b.count > page * ctx.per : tasks.length >= ctx.per) more = true;
|
||||
}
|
||||
if (!more) return seen;
|
||||
}
|
||||
throw new BusError('tracker-paging');
|
||||
}
|
||||
// A list walk keyed by id, so a write landing mid-walk can't shift a page under it.
|
||||
export async function walk(ctx, filter, query = {}) {
|
||||
const items = [];
|
||||
let last = 0;
|
||||
for (let i = 0; i < 100000; i++) {
|
||||
const r = await ctx.sync('GET', `/projects/${ctx.project}/tasks`, {
|
||||
query: {
|
||||
...query,
|
||||
filter: [filter, `id > ${last}`].filter(Boolean).join(' && '),
|
||||
sort_by: 'id',
|
||||
order_by: 'asc',
|
||||
per_page: ctx.per,
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
if (outcome(r) !== 'ok') throw new BusError(refusal(r));
|
||||
if (!Array.isArray(r.json?.items)) shape();
|
||||
for (const t of r.json.items) {
|
||||
checkTask(t);
|
||||
if (t.id <= last || t.project_id !== ctx.project) shape();
|
||||
last = t.id;
|
||||
items.push(t);
|
||||
}
|
||||
if (r.json.items.length < ctx.per) return items;
|
||||
}
|
||||
throw new BusError('tracker-paging');
|
||||
}
|
||||
const changed = (before, after) =>
|
||||
Object.keys(after).filter((k) => JSON.stringify(before?.[k]) !== JSON.stringify(after[k]));
|
||||
// Comments by anyone but this business's bots, newer than the last one seen. The first look at a
|
||||
// task counts only comments created inside the window, so a restart doesn't replay old ones.
|
||||
// `created` is to the second, so the bound is too.
|
||||
async function foreignComments(ctx, id, sinceMs) {
|
||||
const r = await all(ctx.syncCall, null, `/tasks/${id}/comments`, {}, ctx.per);
|
||||
if (!r.ok) throw new BusError(refusal(r.r));
|
||||
const since = Math.floor(sinceMs / 1000) * 1000;
|
||||
const last = ctx.lastComment.get(id);
|
||||
let n = 0,
|
||||
top = last ?? 0;
|
||||
for (const c of r.items) {
|
||||
if (!Number.isSafeInteger(c?.id) || !Number.isSafeInteger(c.author?.id)) shape();
|
||||
top = Math.max(top, c.id);
|
||||
const fresh = last === undefined ? Date.parse(c.created) >= since : c.id > last;
|
||||
if (fresh && !ctx.bots.has(c.author.id)) n++;
|
||||
}
|
||||
ctx.lastComment.set(id, top);
|
||||
return n;
|
||||
}
|
||||
// One task read. Records nothing that a newer self snapshot already covers, and nothing that
|
||||
// matches task_current; a comment count alone is an event without a snapshot.
|
||||
function consider(ctx, current, { task, bucket, via, readAt, comments = 0 }, out) {
|
||||
const r = ref(ctx.project, task.id);
|
||||
const fields = taskFields(task, bucket);
|
||||
const d = digest(fields);
|
||||
const c = current.get(r);
|
||||
const stale = c?.source === 'self' && readAt <= c.at;
|
||||
if (stale || c?.digest === d) {
|
||||
if (comments)
|
||||
out.events.push({
|
||||
kind: 'task.changed.external',
|
||||
subject: r,
|
||||
body: { via, digest: c.digest, previous: c.digest, changed: [], comments },
|
||||
});
|
||||
return;
|
||||
}
|
||||
out.snapshots.push({ task_ref: r, updated: normal(task.updated), digest: d, fields, via, read_at: readAt });
|
||||
out.events.push({
|
||||
kind: 'task.changed.external',
|
||||
subject: r,
|
||||
body: { via, digest: d, previous: c?.digest ?? null, changed: changed(c?.fields, fields), ...(comments ? { comments } : {}) },
|
||||
});
|
||||
}
|
||||
// Open tasks the board no longer shows: done, moved, deleted or no longer shared.
|
||||
async function missing(ctx, current, refs, out) {
|
||||
for (const r of refs) {
|
||||
const c = current.get(r);
|
||||
const id = Number(r.slice(r.indexOf('/') + 1));
|
||||
const readAt = iso(ctx.clock());
|
||||
const x = await ctx.sync('GET', `/tasks/${id}`);
|
||||
let fields, updated, event;
|
||||
if (x.status === 200) {
|
||||
checkTask(x.json);
|
||||
updated = normal(x.json.updated);
|
||||
if (x.json.project_id !== ctx.project) {
|
||||
fields = tombstone('moved', x.json.project_id);
|
||||
event = { reason: 'moved', project: x.json.project_id };
|
||||
} else if (x.json.done) fields = taskFields(x.json, ctx.view.done);
|
||||
else continue; // still open in the project: the board read raced a move, the next tick sees it
|
||||
} else if (outcome(x) === 'not-found' || x.status === 403) {
|
||||
updated = c.updated;
|
||||
fields = tombstone(x.status === 403 ? 'no-access' : 'not-found');
|
||||
event = { reason: fields.gone };
|
||||
} else throw new BusError(refusal(x));
|
||||
const d = digest(fields);
|
||||
if (c.source === 'self' && readAt <= c.at) continue;
|
||||
out.snapshots.push({ task_ref: r, updated, digest: d, fields, via: 'task', read_at: readAt });
|
||||
out.events.push(
|
||||
event
|
||||
? { kind: 'task.missing', subject: r, body: event }
|
||||
: {
|
||||
kind: 'task.changed.external',
|
||||
subject: r,
|
||||
body: { via: 'task', digest: d, previous: c.digest, changed: changed(c.fields, fields) },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const record = (ctx, out) => {
|
||||
if (out.snapshots.length || out.events.length) ctx.broker.recordTask({ business: ctx.business, ...out });
|
||||
};
|
||||
export async function tick(ctx) {
|
||||
const startMs = ctx.clock();
|
||||
const readAt = iso(startMs);
|
||||
const sinceMs = Math.floor(((ctx.lastStart ?? startMs) - WINDOW_MS) / 1000) * 1000;
|
||||
const open = await board(ctx);
|
||||
const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`)).map((t) => [t.id, t]));
|
||||
const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x]));
|
||||
const out = { snapshots: [], events: [] };
|
||||
const comments = new Map();
|
||||
for (const [id, t] of hits) {
|
||||
const key = normal(t.updated);
|
||||
if (ctx.commentsAt.get(id) === key) continue;
|
||||
comments.set(id, await foreignComments(ctx, id, sinceMs));
|
||||
ctx.commentsAt.set(id, key);
|
||||
}
|
||||
for (const [id, { task, bucket }] of open) {
|
||||
const hit = hits.get(id);
|
||||
const use = hit && Date.parse(hit.updated) >= Date.parse(task.updated) ? hit : task;
|
||||
const b = use.done ? ctx.view.done : bucket;
|
||||
consider(ctx, current, { task: use, bucket: b, via: hit ? 'cursor' : 'board', readAt, comments: comments.get(id) }, out);
|
||||
}
|
||||
const explained = new Set();
|
||||
for (const [id, t] of hits) {
|
||||
if (open.has(id)) continue;
|
||||
// An open task the board didn't show raced a move; the next board read places it.
|
||||
if (!t.done) continue;
|
||||
explained.add(ref(ctx.project, id));
|
||||
consider(ctx, current, { task: t, bucket: ctx.view.done, via: 'cursor', readAt, comments: comments.get(id) }, out);
|
||||
}
|
||||
const gone = ctx.broker
|
||||
.taskView(ctx.business, 'open')
|
||||
.map((x) => x.task_ref)
|
||||
.filter((r) => !explained.has(r) && !open.has(Number(r.slice(r.indexOf('/') + 1))));
|
||||
await missing(ctx, current, gone, out);
|
||||
record(ctx, out);
|
||||
ctx.lastStart = startMs;
|
||||
return { snapshots: out.snapshots.length, events: out.events.length };
|
||||
}
|
||||
// The hourly full read, and the first read at startup. comment_count catches comments on tasks the
|
||||
// cursor window missed; a changed count is checked against the comment list before it is reported.
|
||||
export async function reconcile(ctx) {
|
||||
const startMs = ctx.clock();
|
||||
const readAt = iso(startMs);
|
||||
const sinceMs = ctx.lastReconcile ?? startMs;
|
||||
const open = await board(ctx);
|
||||
const list = await walk(ctx, '', { expand: 'comment_count' });
|
||||
const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x]));
|
||||
const out = { snapshots: [], events: [] };
|
||||
const listed = new Set();
|
||||
for (const t of list) {
|
||||
listed.add(ref(ctx.project, t.id));
|
||||
const bucket = t.done ? ctx.view.done : open.get(t.id)?.bucket;
|
||||
if (!bucket) continue;
|
||||
let comments = 0;
|
||||
const count = t.comment_count;
|
||||
if (count !== undefined && !Number.isSafeInteger(count)) shape();
|
||||
const before = ctx.commentCount.get(t.id);
|
||||
if (count !== undefined) ctx.commentCount.set(t.id, count);
|
||||
if (before !== undefined && count !== before) comments = await foreignComments(ctx, t.id, sinceMs);
|
||||
consider(ctx, current, { task: t, bucket, via: 'reconcile', readAt, comments }, out);
|
||||
}
|
||||
const gone = [...current.values()]
|
||||
.filter((x) => x.fields.gone === undefined && !listed.has(x.task_ref))
|
||||
.map((x) => x.task_ref);
|
||||
await missing(ctx, current, gone, out);
|
||||
record(ctx, out);
|
||||
ctx.lastReconcile = startMs;
|
||||
ctx.lastStart ??= startMs;
|
||||
return { snapshots: out.snapshots.length, events: out.events.length };
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { outcome, refusal } from './vikunja.mjs';
|
||||
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
|
||||
import { board, normal } from './sync.mjs';
|
||||
// The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with
|
||||
// the sync token, the caller's expected digest, broker authority, the writes with the acting role's
|
||||
// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at
|
||||
// all is checked by a re-read and never retried (bus README rule).
|
||||
const refuse = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
// Verbs whose fields only the pm writes. Checked before authorize so a refused call consumes nothing.
|
||||
const PM_VERBS = new Set(['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.priority.change', 'task.close']);
|
||||
const STATES = ['todo', 'in-progress', 'in-review', 'blocked'];
|
||||
const RELATIONS = [
|
||||
'subtask',
|
||||
'parenttask',
|
||||
'related',
|
||||
'duplicateof',
|
||||
'duplicates',
|
||||
'blocking',
|
||||
'blocked',
|
||||
'precedes',
|
||||
'follows',
|
||||
'copiedfrom',
|
||||
'copiedto',
|
||||
];
|
||||
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;
|
||||
// Vikunja keeps due dates to the second (review r1, B1), so the milliseconds are dropped before the
|
||||
// write. The snapshot, the event and an uncertain write's re-read then all hold what Vikunja holds.
|
||||
const due = (x) => {
|
||||
if (x !== null && !canonical(x)) refuse('invalid-request');
|
||||
return x === null ? null : `${x.slice(0, 19)}.000Z`;
|
||||
};
|
||||
const priority = (x) => {
|
||||
if (!Number.isSafeInteger(x) || x < 0 || x > 5) refuse('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const percent = (x) => {
|
||||
if (typeof x !== 'number' || !Number.isFinite(x) || x < 0 || x > 1) refuse('invalid-request');
|
||||
return x;
|
||||
};
|
||||
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 sorted = (a) => [...a].sort((x, y) => x - y);
|
||||
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
|
||||
export function verbs(ctx) {
|
||||
const fail = (code) => refuse(code);
|
||||
const label = (id) => {
|
||||
// Second guard after the bot-owner rule (probes.md, L): only label ids in the business file.
|
||||
if (!ctx.labels.has(id)) fail('label-not-allowed');
|
||||
return id;
|
||||
};
|
||||
const relation = (x) => {
|
||||
keys(x, ['kind', 'task_ref'], ['kind', 'task_ref']);
|
||||
if (!RELATIONS.includes(x.kind)) fail('invalid-request');
|
||||
const other = parseRef(x.task_ref);
|
||||
if (other.project !== ctx.project) fail('task-project');
|
||||
return { kind: x.kind, other: other.task };
|
||||
};
|
||||
const target = (taskRef) => {
|
||||
const r = parseRef(taskRef);
|
||||
if (r.project !== ctx.project) fail('task-project');
|
||||
return r.task;
|
||||
};
|
||||
// The live state of one task: the task read plus its board bucket, both with the sync token.
|
||||
async function read(id) {
|
||||
const x = await ctx.sync('GET', `/tasks/${id}`);
|
||||
if (outcome(x) !== 'ok') fail(refusal(x));
|
||||
checkTask(x.json);
|
||||
if (x.json.id !== id) fail('tracker-shape');
|
||||
if (x.json.project_id !== ctx.project) fail('task-moved');
|
||||
let bucket = ctx.view.done;
|
||||
if (!x.json.done) {
|
||||
bucket = (await board(ctx)).get(id)?.bucket;
|
||||
if (!bucket) fail('task-placement');
|
||||
}
|
||||
const fields = taskFields(x.json, bucket);
|
||||
return { task: x.json, bucket, fields, digest: digest(fields), etag: x.etag, updated: normal(x.json.updated) };
|
||||
}
|
||||
const relatedTo = (task, kind, other) =>
|
||||
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
|
||||
// One write with the acting role's token. An uncertain answer is settled by a re-read.
|
||||
async function step(role, method, path, body, id, done) {
|
||||
const r = await ctx.as(role, method, path, { body });
|
||||
const o = outcome(r);
|
||||
if (o === 'ok') return r;
|
||||
if (o === 'uncertain' && id !== null && done) {
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
} catch {
|
||||
// the re-read failed too: the outcome stays unknown
|
||||
}
|
||||
if (after && done(after)) return null;
|
||||
}
|
||||
fail(o === 'uncertain' ? 'write-uncertain' : refusal(r));
|
||||
}
|
||||
return async function handle(cap, verb, args) {
|
||||
const me = ctx.broker.identity(cap);
|
||||
const role = me.role;
|
||||
const mine = ctx.roles[role];
|
||||
if (!mine) fail('tracker-role');
|
||||
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
|
||||
const plan = prepare(verb, args, role, mine);
|
||||
if (verb === 'task.create') return create(cap, role, plan);
|
||||
const id = target(args.task_ref);
|
||||
const before = await read(id);
|
||||
if (args.expect !== undefined && args.expect !== before.digest) {
|
||||
ctx.broker.recordTask({
|
||||
cap,
|
||||
business: ctx.business,
|
||||
events: [{ kind: 'task.conflict', subject: args.task_ref, body: { verb, expected: args.expect, actual: before.digest } }],
|
||||
});
|
||||
fail('task-conflict');
|
||||
}
|
||||
// Reopening a done task is a person's act; no verb writes to one.
|
||||
if (before.task.done) fail('task-done');
|
||||
const work = plan.check(before, id);
|
||||
ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });
|
||||
let failure = null;
|
||||
let landed = 0;
|
||||
try {
|
||||
for (const [method, path, body, done] of work.steps) {
|
||||
await step(role, method, path, body, id, done);
|
||||
landed++;
|
||||
}
|
||||
} catch (e) {
|
||||
failure = e instanceof BusError ? e : new BusError('adapter-failed');
|
||||
}
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
} catch {
|
||||
// handled below: a failed final read must not hide a write that landed (review r1, B2)
|
||||
}
|
||||
// The final read failed after some writes and not others: what the tracker holds isn't known.
|
||||
if (!after && failure) throw landed ? new BusError('write-uncertain') : failure;
|
||||
// Expected fields on success, so a concurrent edit still shows as external on the next poll. When
|
||||
// every write landed but the final read failed, that is still what the writes set, and `updated`
|
||||
// is the compare-read's. On a failure the snapshot is what the tracker holds now.
|
||||
const fields = failure ? after.fields : work.expect(before.fields);
|
||||
const seen = after ?? before;
|
||||
ctx.broker.recordTask({
|
||||
cap,
|
||||
business: ctx.business,
|
||||
snapshots: [{ task_ref: args.task_ref, updated: seen.updated, etag: after?.etag ?? null, digest: digest(fields), fields }],
|
||||
events: failure || !work.event ? [] : [{ ...work.event, subject: args.task_ref }],
|
||||
});
|
||||
if (failure) throw failure;
|
||||
return { task_ref: args.task_ref, digest: digest(fields), updated: seen.updated };
|
||||
};
|
||||
function prepare(verb, args, role, mine) {
|
||||
const common = ['task_ref', 'decision', 'expect'];
|
||||
const plan = { decision: decision(args?.decision) };
|
||||
if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request');
|
||||
switch (verb) {
|
||||
case 'task.create': {
|
||||
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision'], ['title', 'request', 'requirement']);
|
||||
const body = { title: text(args.title, 250, 1) };
|
||||
if (!body.title.trim()) fail('invalid-request');
|
||||
if (args.description !== undefined) body.description = text(args.description, 65536);
|
||||
if (args.due_date !== undefined) body.due_date = due(args.due_date);
|
||||
if (args.priority !== undefined) body.priority = priority(args.priority);
|
||||
plan.body = body;
|
||||
plan.labels = args.labels === undefined ? [] : ids(args.labels).map(label);
|
||||
if (args.relations !== undefined && (!Array.isArray(args.relations) || args.relations.length > 50)) fail('invalid-request');
|
||||
plan.relations = (args.relations ?? []).map(relation);
|
||||
plan.request = text(args.request, 64, 1);
|
||||
plan.requirement = text(args.requirement, 64);
|
||||
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
|
||||
return plan;
|
||||
}
|
||||
case 'task.assign':
|
||||
case 'task.reassign': {
|
||||
keys(args, [...common, 'role'], ['task_ref', 'role']);
|
||||
const to = ctx.roles[text(args.role, 64, 1)];
|
||||
if (!to || !Object.hasOwn(ctx.roles, args.role)) fail('unknown-role');
|
||||
const bots = new Map(Object.entries(ctx.roles).map(([r, x]) => [x.botId, r]));
|
||||
plan.check = ({ fields }, id) => {
|
||||
const held = fields.assignees.filter((u) => bots.has(u));
|
||||
const previous = held.map((u) => bots.get(u)).sort();
|
||||
if (verb === 'task.assign' && held.length) fail('task-assigned');
|
||||
if (verb === 'task.reassign' && held.length === 1 && held[0] === to.botId) fail('task-assigned');
|
||||
if (verb === 'task.reassign' && !held.length) fail('task-unassigned');
|
||||
const steps = [];
|
||||
if (!held.includes(to.botId))
|
||||
steps.push(['POST', `/tasks/${id}/assignees`, { user_id: to.botId }, (a) => a.fields.assignees.includes(to.botId)]);
|
||||
// Unassign answers 204 for a user who isn't assigned (probes.md, D), so only users the
|
||||
// compare-read showed are removed.
|
||||
for (const u of held.filter((u) => u !== to.botId))
|
||||
steps.push(['DELETE', `/tasks/${id}/assignees/${u}`, undefined, (a) => !a.fields.assignees.includes(u)]);
|
||||
return {
|
||||
steps,
|
||||
expect: (f) => ({ ...f, assignees: sorted([...f.assignees.filter((u) => !bots.has(u)), to.botId]) }),
|
||||
event: { kind: 'task.assigned', body: { role: args.role, previous } },
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
case 'task.schedule': {
|
||||
keys(args, [...common, 'due_date', 'labels', 'relations'], ['task_ref']);
|
||||
if (args.due_date === undefined && args.labels === undefined && args.relations === undefined) fail('invalid-request');
|
||||
const labels = { add: [], remove: [] };
|
||||
if (args.labels !== undefined) {
|
||||
keys(args.labels, ['add', 'remove']);
|
||||
labels.add = ids(args.labels.add ?? []).map(label);
|
||||
labels.remove = ids(args.labels.remove ?? []).map(label);
|
||||
}
|
||||
const list = (x) => {
|
||||
if (x !== undefined && (!Array.isArray(x) || x.length > 50)) fail('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const relations = args.relations === undefined ? { add: [], remove: [] } : (keys(args.relations, ['add', 'remove']), {
|
||||
add: (list(args.relations.add) ?? []).map(relation),
|
||||
remove: (list(args.relations.remove) ?? []).map(relation),
|
||||
});
|
||||
const dueDate = args.due_date === undefined ? undefined : due(args.due_date);
|
||||
plan.check = ({ task, fields }, id) => {
|
||||
const steps = [];
|
||||
const iso = dueDate === undefined ? undefined : dueDate;
|
||||
if (iso !== undefined && iso !== fields.due_date)
|
||||
steps.push(['PATCH', `/tasks/${id}`, { due_date: iso }, (a) => a.fields.due_date === iso]);
|
||||
for (const l of labels.add.filter((l) => !fields.labels.includes(l)))
|
||||
steps.push(['POST', `/tasks/${id}/labels`, { label_id: l }, (a) => a.fields.labels.includes(l)]);
|
||||
// A label that isn't on the task answers 403 to a delete, so only labels shown are removed.
|
||||
for (const l of labels.remove.filter((l) => fields.labels.includes(l)))
|
||||
steps.push(['DELETE', `/tasks/${id}/labels/${l}`, undefined, (a) => !a.fields.labels.includes(l)]);
|
||||
for (const r of relations.add.filter((r) => !relatedTo(task, r.kind, r.other)))
|
||||
steps.push(['POST', `/tasks/${id}/relations`, { other_task_id: r.other, relation_kind: r.kind }, (a) => relatedTo(a.task, r.kind, r.other)]);
|
||||
for (const r of relations.remove.filter((r) => relatedTo(task, r.kind, r.other)))
|
||||
steps.push(['DELETE', `/tasks/${id}/relations/${r.kind}/${r.other}`, undefined, (a) => !relatedTo(a.task, r.kind, r.other)]);
|
||||
return {
|
||||
steps,
|
||||
expect: (f) => ({
|
||||
...f,
|
||||
due_date: iso === undefined ? f.due_date : iso,
|
||||
labels: sorted([...new Set([...f.labels.filter((l) => !labels.remove.includes(l)), ...labels.add])]),
|
||||
}),
|
||||
event: null,
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
case 'task.priority.change': {
|
||||
keys(args, [...common, 'priority'], ['task_ref', 'priority']);
|
||||
const p = priority(args.priority);
|
||||
plan.check = ({ fields }, id) => ({
|
||||
steps: p === fields.priority ? [] : [['PATCH', `/tasks/${id}`, { priority: p }, (a) => a.fields.priority === p]],
|
||||
expect: (f) => ({ ...f, priority: p }),
|
||||
event: null,
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
case 'task.scope.change': {
|
||||
keys(args, [...common, 'title', 'description'], ['task_ref']);
|
||||
const body = {};
|
||||
if (args.title !== undefined) body.title = text(args.title, 250, 1);
|
||||
if (body.title !== undefined && !body.title.trim()) fail('invalid-request');
|
||||
if (args.description !== undefined) body.description = text(args.description, 65536);
|
||||
if (!Object.keys(body).length) fail('invalid-request');
|
||||
plan.check = ({ fields }, id) => {
|
||||
const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v));
|
||||
return {
|
||||
steps: Object.keys(send).length
|
||||
? [['PATCH', `/tasks/${id}`, send, (a) => Object.entries(send).every(([k, v]) => a.fields[k] === v)]]
|
||||
: [],
|
||||
expect: (f) => ({ ...f, ...body }),
|
||||
event: null,
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
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 && !STATES.includes(args.state)) fail('invalid-state');
|
||||
const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1);
|
||||
if (pct === undefined && args.state === undefined && note === undefined) fail('invalid-request');
|
||||
plan.check = ({ fields }, id) => {
|
||||
if (!fields.assignees.includes(mine.botId)) fail('not-assigned');
|
||||
const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state];
|
||||
const steps = [];
|
||||
if (pct !== undefined && pct !== fields.percent_done)
|
||||
steps.push(['PATCH', `/tasks/${id}`, { percent_done: pct }, (a) => a.fields.percent_done === pct]);
|
||||
if (to !== fields.bucket)
|
||||
steps.push([
|
||||
'PUT',
|
||||
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${to}/tasks`,
|
||||
{ task_id: id },
|
||||
(a) => a.bucket === to,
|
||||
]);
|
||||
// Comment text stays in the tracker; the event records only that there was one.
|
||||
if (note !== undefined) steps.push(['POST', `/tasks/${id}/comments`, { comment: note }, null]);
|
||||
return {
|
||||
steps,
|
||||
expect: (f) => ({ ...f, bucket: to, percent_done: pct ?? f.percent_done }),
|
||||
event: {
|
||||
kind: 'task.state',
|
||||
body: {
|
||||
role,
|
||||
state: ctx.view.titles[to],
|
||||
previous: ctx.view.titles[fields.bucket] ?? null,
|
||||
percent_done: pct ?? fields.percent_done,
|
||||
comment: note !== undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
case 'task.close': {
|
||||
keys(args, [...common, 'verdict'], ['task_ref', 'verdict']);
|
||||
// The queue holds review verdicts in slice 1. This is a citation, recorded and not checked.
|
||||
const verdict = text(args.verdict, 256, 1);
|
||||
plan.check = (_before, id) => ({
|
||||
steps: [
|
||||
[
|
||||
'PUT',
|
||||
`/projects/${ctx.project}/views/${ctx.view.id}/buckets/${ctx.view.done}/tasks`,
|
||||
{ task_id: id },
|
||||
(a) => a.task.done && a.bucket === ctx.view.done,
|
||||
],
|
||||
],
|
||||
expect: (f) => ({ ...f, done: true, bucket: ctx.view.done }),
|
||||
event: { kind: 'task.closed', body: { verdict } },
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
default:
|
||||
fail('unknown-verb');
|
||||
}
|
||||
}
|
||||
async function create(cap, role, plan) {
|
||||
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
|
||||
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
|
||||
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
|
||||
const r = await step(role, 'POST', `/projects/${ctx.project}/tasks`, plan.body, null, null);
|
||||
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
|
||||
checkTask(r.json);
|
||||
const id = r.json.id;
|
||||
const taskRef = ref(ctx.project, id);
|
||||
let failure = null;
|
||||
try {
|
||||
for (const l of plan.labels)
|
||||
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, id, (a) => a.fields.labels.includes(l));
|
||||
for (const x of plan.relations)
|
||||
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, id, (a) =>
|
||||
relatedTo(a.task, x.kind, x.other),
|
||||
);
|
||||
} catch (e) {
|
||||
failure = e instanceof BusError ? e : new BusError('adapter-failed');
|
||||
}
|
||||
let after = null;
|
||||
try {
|
||||
after = await read(id);
|
||||
} catch {
|
||||
// handled below, as in handle()
|
||||
}
|
||||
// Every write landed but the final read failed: the create's answer plus the labels written, in
|
||||
// the default bucket. A refusal here would read as "nothing happened" and invite a second create.
|
||||
if (!after && !failure) {
|
||||
const fields = { ...taskFields(r.json, ctx.view.ids.todo), labels: sorted(plan.labels) };
|
||||
after = { fields, digest: digest(fields), etag: null, updated: normal(r.json.updated) };
|
||||
}
|
||||
// task.created is recorded even when a later label or relation write failed: the task exists.
|
||||
ctx.broker.recordTask({
|
||||
cap,
|
||||
business: ctx.business,
|
||||
snapshots: after
|
||||
? [{ task_ref: taskRef, updated: after.updated, etag: after.etag ?? null, digest: after.digest, fields: after.fields }]
|
||||
: [],
|
||||
events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement } }],
|
||||
});
|
||||
if (failure) throw failure;
|
||||
return { task_ref: taskRef, digest: after.digest, updated: after.updated };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
// Vikunja v2 HTTP client. It never throws: a network error or timeout is status 0, so
|
||||
// credentials.use() gets a result back instead of flattening a throw to service-failed.
|
||||
// The token goes only into the Authorization header; nothing here logs.
|
||||
export function apiRoot(base) {
|
||||
let u;
|
||||
try {
|
||||
u = new URL(base);
|
||||
} catch {
|
||||
throw new BusError('tracker-config');
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(u.protocol) ||
|
||||
u.username ||
|
||||
u.password ||
|
||||
u.search ||
|
||||
u.hash ||
|
||||
!['', '/'].includes(u.pathname)
|
||||
)
|
||||
throw new BusError('tracker-config');
|
||||
return u.origin + '/api/v2';
|
||||
}
|
||||
// URLSearchParams writes a space as '+'; the probes sent %20, so this does too.
|
||||
const query = (q) =>
|
||||
Object.entries(q ?? {})
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
export function client({ baseUrl, fetch = globalThis.fetch, timeoutMs = 15000 }) {
|
||||
const root = apiRoot(baseUrl);
|
||||
return async function call(token, method, path, { body, query: q, etag } = {}) {
|
||||
const qs = query(q);
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (etag) headers['If-None-Match'] = etag;
|
||||
try {
|
||||
const r = await fetch(root + path + (qs ? '?' + qs : ''), {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await r.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { status: r.status, json, etag: r.headers.get('etag') };
|
||||
} catch {
|
||||
return { status: 0, json: null, etag: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
// What a status means to S3. 5xx and network errors on a write leave the outcome unknown.
|
||||
export function outcome(r) {
|
||||
const s = r.status;
|
||||
if ((s >= 200 && s < 300) || s === 304) return 'ok';
|
||||
if (s === 401) return 'unauthorized';
|
||||
if (s === 403) return 'forbidden';
|
||||
if (s === 404) return r.json?.code === 4002 ? 'not-found' : 'missing';
|
||||
if (s === 400 || s === 422) return 'invalid';
|
||||
if (s === 429) return 'rate-limited';
|
||||
if (s === 0 || s >= 500) return 'uncertain';
|
||||
return 'unexpected';
|
||||
}
|
||||
const CODES = {
|
||||
unauthorized: 'tracker-unauthorized',
|
||||
forbidden: 'tracker-forbidden',
|
||||
'not-found': 'task-not-found',
|
||||
missing: 'tracker-not-found',
|
||||
invalid: 'tracker-invalid',
|
||||
'rate-limited': 'tracker-rate-limited',
|
||||
uncertain: 'tracker-unavailable',
|
||||
unexpected: 'tracker-unexpected',
|
||||
};
|
||||
export const refusal = (r) => CODES[outcome(r)] ?? 'tracker-unexpected';
|
||||
// Every item of a paginated v2 list. `page` is 1-based; a short page ends the walk.
|
||||
export async function all(call, token, path, q = {}, per = 50, max = 200) {
|
||||
const items = [];
|
||||
for (let page = 1; page <= max; page++) {
|
||||
const r = await call(token, 'GET', path, { query: { ...q, per_page: per, page } });
|
||||
if (outcome(r) !== 'ok' || !Array.isArray(r.json?.items)) return { ok: false, r, items };
|
||||
items.push(...r.json.items);
|
||||
if (r.json.items.length < per || page >= (r.json.total_pages ?? Infinity)) return { ok: true, r, items };
|
||||
}
|
||||
return { ok: false, r: { status: 0, json: null }, items };
|
||||
}
|
||||
Reference in New Issue
Block a user