vikunja: row 94 follow-ups filed as P19 #251 (decision 110)

Dewey's notes 6 to 10 from #1588 comments 28075 and 28104.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 08:07:17 -05:00
co-authored by Claude Opus 5.5
parent 0cada46e79
commit ba7553ec7a
2 changed files with 85 additions and 0 deletions
@@ -0,0 +1,17 @@
{
"at": "2026-10-13T13:07:11.680Z",
"project": 19,
"created": [
{
"title": "Messages follow-up from row 94: Ctrl-C window, inbox paging, nav count, Board count, control characters",
"id": 251,
"index": 94,
"inverse": "mark done: POST /api/v1/tasks/251 done true (pm); delete is Jason's or ops-01's",
"readBack": 200,
"project_back": 19,
"due_back": "0001-01-01T00:00:00Z",
"done": false
}
],
"problems": []
}
@@ -0,0 +1,68 @@
// Sage, 2026-10-13: decision 110. Files row 94's non-blocking follow-ups (#1588) as one task in project 19
// with Sage's own key (bot user 13). Refuses if the title already exists. The sync key reads it back. No
// labels, no assignees, no bucket moves. Rollback is marking the task done; deletion is Jason's or ops-01's.
// Prints no secret.
// Usage: node file-r94-followup.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const p = (...xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(SIG)}</p>`;
const TASKS = [
{ title: "Messages follow-up from row 94: Ctrl-C window, inbox paging, nav count, Board count, control characters",
description: p("Row 94 (Gitea #1588) landed as 2b2a9e14. Dewey left five non-blocking notes in round 1 (comment 28075, notes 6 to 10) and kept them in the round 2 approval (comment 28104). Round 2 didn't change the product code they describe.",
"1. A Ctrl-C during humanTransport's spawnSync reaches the child, which shares the process group, and the call fails with outcome-unknown. During message.receive the fetched messages are delivered but never printed, and follow never shows them again. During message.read the message prints but stays unread. Plain talk had the same window; follow makes Ctrl-C the normal way to stop. Let the child finish, or have the SIGINT handler only set the abort flag between calls.",
"2. Text inbox walks every page of the history at limit 200 even after it has as many unread messages as the count says. Stop at the unread count. Row 99 (#1593) changes the same branch, so do this after it lands.",
"3. The Messages nav item shows 0 when nothing is unread; the Inbox badge hides when empty. Make them match.",
"4. On the Board, a failed messages read that isn't a refusal keeps the previous messageCount; the other pages set it to null. Make them match.",
"5. Message bodies print to the terminal as stored. one() flattens whitespace, but control characters pass. Decisions and talk do the same today, so fix all three together.",
"Touches packages/cli and packages/webui: decision 109 notices to Mos apply, and public/ changes need Mos's go (decision 123).") },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
async function existing() {
const out = [];
for (let page = 1; page < 20; page++) {
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
out.push(...list(r.json));
if (list(r.json).length < 50) return out;
}
throw new Error("more than 19 pages");
}
const rec = { at: new Date().toISOString(), project: PROJECT, created: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
const before = await existing();
const clash = before.filter((t) => TASKS.some((x) => x.title === t.title));
if (clash.length) throw new Error(`exists: ${clash.map((t) => `#${t.id} ${t.title}`).join("; ")}; refusing`);
writeFileSync(OUT, "", { flag: "wx" });
for (const t of TASKS) {
rec.created.push({ title: t.title });
save();
const body = { title: t.title, description: t.description, ...(t.due ? { due_date: t.due } : {}) };
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, body, key);
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) {
rec.problems.push(`create ${t.title}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`);
break;
}
Object.assign(rec.created.at(-1), { id: r.json.id, index: r.json.index, inverse: `mark done: POST /api/v1/tasks/${r.json.id} done true (pm); delete is Jason's or ops-01's` });
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
Object.assign(rec.created.at(-1), { readBack: back.status, project_back: back.json?.project_id, due_back: back.json?.due_date, done: back.json?.done });
if (back.status !== 200 || back.json?.title !== t.title || back.json?.project_id !== PROJECT || back.json?.done) rec.problems.push(`${t.title} read-back differs`);
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);