claude-code-kanban 7.5.0 → 7.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub-app.json +5 -0
- package/lib/panes.js +16 -2
- package/lib/parsers.js +27 -2
- package/lib/retention.js +29 -6
- package/lib/session-events.js +154 -42
- package/lib/show.js +385 -0
- package/lib/terminal-client.js +14 -1
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +27 -7
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +66 -10
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +69 -10
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
- package/public/app.js +1149 -108
- package/public/index.html +7 -6
- package/public/style.css +770 -9
- package/public/terminal-frame.js +0 -2
- package/public/tree-highlight.js +137 -15
- package/server.js +71 -24
- package/lib/dispatch.js +0 -32
package/hub-app.json
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
8
|
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s"] },
|
|
9
|
+
"plugin": {
|
|
10
|
+
"id": "claude-code-kanban@claude-code-kanban",
|
|
11
|
+
"path": "plugin/plugins/claude-code-kanban",
|
|
12
|
+
"install": "claude-code-kanban --install --yes"
|
|
13
|
+
},
|
|
9
14
|
"provides": {
|
|
10
15
|
"projects": { "path": "/api/projects" },
|
|
11
16
|
"terminal": { "liveWork": "/api/terminals" }
|
package/lib/panes.js
CHANGED
|
@@ -124,7 +124,8 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
// The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
|
|
127
|
-
|
|
127
|
+
// `show` marks a show card's file, which the board renders with the card's theme.
|
|
128
|
+
function add(sessionId, { kind, target, title, show }) {
|
|
128
129
|
if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
|
|
129
130
|
if (kind === 'message' && target.length > MAX_MESSAGE_TARGET) {
|
|
130
131
|
throw httpError(400, `A message target holds at most ${MAX_MESSAGE_TARGET} characters`, 'bad_target');
|
|
@@ -140,6 +141,7 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
|
|
|
140
141
|
kind,
|
|
141
142
|
target,
|
|
142
143
|
title: paneTitle(kind, target, title),
|
|
144
|
+
...(show && { show: true }),
|
|
143
145
|
addedAt: now(),
|
|
144
146
|
};
|
|
145
147
|
layout.panes.push(pane);
|
|
@@ -157,6 +159,18 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
|
|
|
157
159
|
return write(sessionId, layout);
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
// Drops the show panes of these files; returns the new layout, or null when none matched.
|
|
163
|
+
function removeShow(sessionId, files) {
|
|
164
|
+
reload();
|
|
165
|
+
const layout = bySession.get(sessionId);
|
|
166
|
+
if (!layout) return null;
|
|
167
|
+
const keys = new Set(files.map((f) => targetKey('html', f)));
|
|
168
|
+
const kept = layout.panes.filter((p) => !(p.show && keys.has(targetKey(p.kind, p.target))));
|
|
169
|
+
if (kept.length === layout.panes.length) return null;
|
|
170
|
+
layout.panes = kept;
|
|
171
|
+
return write(sessionId, layout);
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
// An empty title gives the pane its default title back. Returns null when the session has no such pane.
|
|
161
175
|
function rename(sessionId, paneId, title) {
|
|
162
176
|
reload();
|
|
@@ -197,7 +211,7 @@ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
|
|
|
197
211
|
return out;
|
|
198
212
|
}
|
|
199
213
|
|
|
200
|
-
return { get, add, remove, rename, reorder, prune, reload, fileTargets };
|
|
214
|
+
return { get, add, remove, removeShow, rename, reorder, prune, reload, fileTargets };
|
|
201
215
|
}
|
|
202
216
|
|
|
203
217
|
module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
|
package/lib/parsers.js
CHANGED
|
@@ -1634,7 +1634,7 @@ async function readCompactSummaries(jsonlPath) {
|
|
|
1634
1634
|
if (!text) continue;
|
|
1635
1635
|
// Strip the "This session is being continued..." preamble if present.
|
|
1636
1636
|
text = text.replace(/^This session is being continued[^\n]*\n+(The summary below[^\n]*\n+)?/i, '').trim();
|
|
1637
|
-
if (text) results.push({ timestamp: obj.timestamp, summary: text });
|
|
1637
|
+
if (text) results.push({ timestamp: obj.timestamp, summary: text, source: 'inline' });
|
|
1638
1638
|
} catch (_) {}
|
|
1639
1639
|
}
|
|
1640
1640
|
} catch (_) {}
|
|
@@ -1665,7 +1665,7 @@ async function readCompactSummaries(jsonlPath) {
|
|
|
1665
1665
|
for (const b of blocks) {
|
|
1666
1666
|
if (b.type !== 'text' || !b.text) continue;
|
|
1667
1667
|
const match = b.text.match(/<summary>([\s\S]*?)(?:<\/summary>|$)/);
|
|
1668
|
-
if (match) { results.push({ timestamp: lastTs, summary: match[1].trim() }); found = true; break; }
|
|
1668
|
+
if (match) { results.push({ timestamp: lastTs, summary: match[1].trim(), source: 'subagent' }); found = true; break; }
|
|
1669
1669
|
}
|
|
1670
1670
|
if (found) break;
|
|
1671
1671
|
} catch (_) {}
|
|
@@ -1675,6 +1675,30 @@ async function readCompactSummaries(jsonlPath) {
|
|
|
1675
1675
|
return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
1676
1676
|
}
|
|
1677
1677
|
|
|
1678
|
+
// A subagent summary's timestamp is the compact agent's last record, written just
|
|
1679
|
+
// before the parent's isCompactSummary record; compactions are minutes apart.
|
|
1680
|
+
const COMPACT_SUMMARY_MATCH_MS = 2 * 60 * 1000;
|
|
1681
|
+
|
|
1682
|
+
function fillCompactSummaries(chips, summaries) {
|
|
1683
|
+
const candidates = summaries.filter(s => s.source === 'subagent' && s.timestamp);
|
|
1684
|
+
const used = new Set();
|
|
1685
|
+
for (const chip of chips) {
|
|
1686
|
+
if (chip.compactSummary || !chip.timestamp) continue;
|
|
1687
|
+
const at = Date.parse(chip.timestamp);
|
|
1688
|
+
let best = null;
|
|
1689
|
+
let bestGap = COMPACT_SUMMARY_MATCH_MS;
|
|
1690
|
+
for (const s of candidates) {
|
|
1691
|
+
if (used.has(s)) continue;
|
|
1692
|
+
const gap = Math.abs(Date.parse(s.timestamp) - at);
|
|
1693
|
+
if (gap <= bestGap) { best = s; bestGap = gap; }
|
|
1694
|
+
}
|
|
1695
|
+
if (best) {
|
|
1696
|
+
used.add(best);
|
|
1697
|
+
chip.compactSummary = best.summary;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1678
1702
|
async function findTerminatedTeammates(jsonlPath) {
|
|
1679
1703
|
return (await buildSessionDigest(jsonlPath)).terminated;
|
|
1680
1704
|
}
|
|
@@ -2028,6 +2052,7 @@ module.exports = {
|
|
|
2028
2052
|
buildAgentProgressMap,
|
|
2029
2053
|
buildSessionDigest,
|
|
2030
2054
|
readCompactSummaries,
|
|
2055
|
+
fillCompactSummaries,
|
|
2031
2056
|
readArtifactLinks,
|
|
2032
2057
|
readScratchpadCreations,
|
|
2033
2058
|
findTerminatedTeammates,
|
package/lib/retention.js
CHANGED
|
@@ -38,11 +38,24 @@ function pruneMap(map, atOf, { known, maxAgeMs, now }) {
|
|
|
38
38
|
return removed;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const textOrNull = (v) => (typeof v === 'string' && v ? v : null);
|
|
42
|
+
|
|
43
|
+
function dispatchedEntry(e) {
|
|
44
|
+
return {
|
|
45
|
+
parent: textOrNull(e.parent),
|
|
46
|
+
at: e.at,
|
|
47
|
+
name: textOrNull(e.name),
|
|
48
|
+
group: textOrNull(e.group),
|
|
49
|
+
cwd: textOrNull(e.cwd),
|
|
50
|
+
worktree: e.worktree === true ? true : textOrNull(e.worktree),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/**
|
|
42
55
|
* Sessions started through `dispatch start`, kept after their terminal ends so the card
|
|
43
|
-
* keeps its marker.
|
|
56
|
+
* keeps its marker, and across a restart so `dispatch list` finds a session cck resumes.
|
|
44
57
|
* @param {object} o
|
|
45
|
-
* @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at}}}` or null
|
|
58
|
+
* @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at, name, group, cwd, worktree}}}` or null
|
|
46
59
|
* @param {(data: object) => void} o.save
|
|
47
60
|
*/
|
|
48
61
|
function createDispatchedStore({ load, save, now = Date.now }) {
|
|
@@ -52,15 +65,15 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
52
65
|
if (saved && typeof saved === 'object') {
|
|
53
66
|
for (const [id, e] of Object.entries(saved)) {
|
|
54
67
|
if (!e || !Number.isFinite(e.at)) continue;
|
|
55
|
-
entries.set(id,
|
|
68
|
+
entries.set(id, dispatchedEntry(e));
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
const persist = () => save({ version: 1, sessions: Object.fromEntries(entries) });
|
|
60
73
|
|
|
61
|
-
function record(id,
|
|
74
|
+
function record(id, info = {}) {
|
|
62
75
|
entries.delete(id);
|
|
63
|
-
entries.set(id, {
|
|
76
|
+
entries.set(id, dispatchedEntry({ ...info, at: now() }));
|
|
64
77
|
if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
|
|
65
78
|
persist();
|
|
66
79
|
}
|
|
@@ -72,7 +85,17 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
72
85
|
return removed;
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
|
|
88
|
+
// Derived, not kept: cck resumes every terminal on restart, and a list kept in memory
|
|
89
|
+
// would lose those sessions.
|
|
90
|
+
function running(isRunning, { parent } = {}) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const [session, { at, ...m }] of entries) {
|
|
93
|
+
if (isRunning(session) && (!parent || m.parent === parent)) out.push({ session, ...m, startedAt: at });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { record, prune, running, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
|
|
76
99
|
}
|
|
77
100
|
|
|
78
101
|
// Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
|
package/lib/session-events.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// bucket-map invariants here are the difference between a bounded queue and a map that
|
|
4
4
|
// grows one permanent entry per session id ever named in a request path.
|
|
5
5
|
|
|
6
|
+
const crypto = require('node:crypto');
|
|
6
7
|
const { oneLine } = require('./one-line');
|
|
7
8
|
|
|
8
9
|
// Tells a live session that the board moved one of its tasks. The plugin's doorbell mod
|
|
@@ -14,6 +15,11 @@ const { oneLine } = require('./one-line');
|
|
|
14
15
|
// disk queue would buy nothing. Reading consumes, so a restarted session never replays
|
|
15
16
|
// a backlog and acts on the same move twice.
|
|
16
17
|
//
|
|
18
|
+
// A mod that acks changes when a line leaves: on its ack, not on the read. A line with a
|
|
19
|
+
// `marker` (a review) is the durable exception. Its comments exist nowhere else the agent
|
|
20
|
+
// reads, so it survives the first-attach drop and /clear, and server.js keeps the marker
|
|
21
|
+
// file until the ack, which `restore` reads back after a board restart.
|
|
22
|
+
//
|
|
17
23
|
// A bucket exists only while it holds something: an undelivered line or a waiting
|
|
18
24
|
// poller. Without that, the map would grow one permanent entry per session id ever
|
|
19
25
|
// asked for -- and the id comes straight off the request path.
|
|
@@ -32,14 +38,12 @@ const sanitizeEventLine = (line) => oneLine(line, 1500);
|
|
|
32
38
|
// marks the board as the sender.
|
|
33
39
|
const EVENT_PREFIX = '[kanban board]';
|
|
34
40
|
|
|
35
|
-
function
|
|
36
|
-
if (to === 'in_progress') {
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
if (to === 'cancelled') return 'Abandon it. Undo nothing unless asked.';
|
|
42
|
-
return '';
|
|
41
|
+
function moveCommand(from, to, ref) {
|
|
42
|
+
if (to === 'in_progress') return `Start task ${ref} now. Mark it completed when done.`;
|
|
43
|
+
if (from === 'in_progress' && to === 'pending') return `Stop work on task ${ref} and leave it for later.`;
|
|
44
|
+
if (to === 'completed') return `Stop work on task ${ref}: it is done.`;
|
|
45
|
+
if (to === 'cancelled') return `Drop task ${ref}. Keep the changes made so far.`;
|
|
46
|
+
return `Task ${ref} is now ${to}.`;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
// Everything after `Description:` is the description verbatim to end of line, so no amount
|
|
@@ -47,9 +51,7 @@ function moveMeaning(from, to) {
|
|
|
47
51
|
// value that needs delimiting.
|
|
48
52
|
function formatTaskMoved(taskId, prevStatus, task) {
|
|
49
53
|
const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
|
|
50
|
-
const
|
|
51
|
-
const meaning = moveMeaning(prevStatus, task.status);
|
|
52
|
-
const head = `${EVENT_PREFIX} The user moved task ${taskId} "${subject}"${from} to ${task.status}.${meaning ? ` ${meaning}` : ''}`;
|
|
54
|
+
const head = `${EVENT_PREFIX} ${moveCommand(prevStatus, task.status, `${taskId} "${subject}"`)}`;
|
|
53
55
|
return task.description ? `${head} Description: ${task.description}` : head;
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -63,30 +65,98 @@ function boardEventsOn(cfg) {
|
|
|
63
65
|
// of line, so a space in the config dir cannot split it.
|
|
64
66
|
function formatReviewSubmitted(count, label, reviewFile) {
|
|
65
67
|
const comments = `${count} review comment${count === 1 ? '' : 's'}`;
|
|
66
|
-
return `${EVENT_PREFIX}
|
|
68
|
+
return `${EVENT_PREFIX} Address ${comments} on ${label}: ${reviewFile}`;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
// Names this board run in every reply, so a mod drops `got` and `ack` counted against a run
|
|
72
|
+
// that has since restarted.
|
|
73
|
+
const BOARD_RUN = crypto.randomBytes(6).toString('hex');
|
|
74
|
+
let lastSeq = 0;
|
|
75
|
+
|
|
76
|
+
// Ids come off request paths, so both maps are capped; the oldest entry goes first.
|
|
77
|
+
const MAX_IDS = 500;
|
|
78
|
+
const doorbells = new Map();
|
|
79
|
+
const aliases = new Map();
|
|
80
|
+
|
|
81
|
+
function remember(map, key, value) {
|
|
82
|
+
map.delete(key);
|
|
83
|
+
map.set(key, value);
|
|
84
|
+
if (map.size > MAX_IDS) map.delete(map.keys().next().value);
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
// Set by server.js: `onDelivered(entry)` runs once a review line is acked (or drained by a
|
|
88
|
+
// mod without acks); `restore(sessionId)` returns the review lines still pending on disk.
|
|
89
|
+
const hooks = { onDelivered: null, restore: null };
|
|
90
|
+
|
|
91
|
+
function configureSessionEvents(opts) {
|
|
92
|
+
Object.assign(hooks, opts);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// After /clear the mod polls under a new id, and the board may still send to the old one.
|
|
96
|
+
// `adopt` keeps each alias pointing at the newest id, so one lookup is enough.
|
|
97
|
+
function resolveSessionId(sessionId) {
|
|
98
|
+
return aliases.get(sessionId) ?? sessionId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function bucketFor(key) {
|
|
102
|
+
let bucket = sessionEventBuckets.get(key);
|
|
80
103
|
if (!bucket) {
|
|
81
104
|
bucket = { queue: [], waiters: new Set() };
|
|
82
|
-
sessionEventBuckets.set(
|
|
105
|
+
sessionEventBuckets.set(key, bucket);
|
|
83
106
|
}
|
|
84
|
-
bucket
|
|
107
|
+
return bucket;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function partition(list, pred) {
|
|
111
|
+
const yes = [];
|
|
112
|
+
const no = [];
|
|
113
|
+
for (const e of list) (pred(e) ? yes : no).push(e);
|
|
114
|
+
return [yes, no];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// True once this session's doorbell has polled during this board run. A review then always
|
|
118
|
+
// goes into the queue: the mod takes it on its next poll, so a busy turn only delays it.
|
|
119
|
+
function hasDoorbell(sessionId) {
|
|
120
|
+
return doorbells.has(resolveSessionId(sessionId));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function enqueueSessionEvent(sessionId, line, { marker } = {}) {
|
|
124
|
+
const text = sanitizeEventLine(line);
|
|
125
|
+
if (!sessionId || !text) return;
|
|
126
|
+
const bucket = bucketFor(resolveSessionId(sessionId));
|
|
127
|
+
bucket.queue.push({ seq: ++lastSeq, text, marker });
|
|
85
128
|
// A session with no doorbell attached must not grow without bound.
|
|
86
129
|
if (bucket.queue.length > 50) bucket.queue.splice(0, bucket.queue.length - 50);
|
|
87
130
|
for (const wake of [...bucket.waiters]) wake();
|
|
88
131
|
}
|
|
89
132
|
|
|
133
|
+
function delivered(entries) {
|
|
134
|
+
for (const e of entries) if (e.marker) hooks.onDelivered?.(e);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function adopt(prev, sessionId) {
|
|
138
|
+
if (!prev || prev === sessionId) return;
|
|
139
|
+
for (const [from, to] of aliases) if (to === prev) aliases.set(from, sessionId);
|
|
140
|
+
remember(aliases, prev, sessionId);
|
|
141
|
+
const old = sessionEventBuckets.get(prev);
|
|
142
|
+
if (!old) return;
|
|
143
|
+
const [durable, rest] = partition(old.queue, (e) => e.marker);
|
|
144
|
+
old.queue = rest;
|
|
145
|
+
evictIfEmpty(prev, old);
|
|
146
|
+
if (!durable.length) return;
|
|
147
|
+
const bucket = bucketFor(sessionId);
|
|
148
|
+
bucket.queue.push(...durable);
|
|
149
|
+
bucket.queue.sort((a, b) => a.seq - b.seq);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function restorePending(sessionId) {
|
|
153
|
+
const lines = await hooks.restore(sessionId).catch(() => []);
|
|
154
|
+
const queued = new Set(sessionEventBuckets.get(sessionId)?.queue.map((e) => e.marker));
|
|
155
|
+
for (const { text, marker } of lines) {
|
|
156
|
+
if (!queued.has(marker)) enqueueSessionEvent(sessionId, text, { marker });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
90
160
|
const MAX_WAIT_SEC = 120;
|
|
91
161
|
const TOPIC_RE = /^[a-z]+$/;
|
|
92
162
|
|
|
@@ -100,46 +170,87 @@ function topicKey(topic, sessionId) {
|
|
|
100
170
|
return topic ? `${topic}:${sessionId}` : sessionId;
|
|
101
171
|
}
|
|
102
172
|
|
|
173
|
+
function seqParam(value) {
|
|
174
|
+
const n = Number(value);
|
|
175
|
+
return Number.isSafeInteger(n) && n > 0 ? n : 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
103
178
|
// Long-poll drained by the doorbell mod. Routing stays in server.js; this is the handler.
|
|
104
|
-
|
|
179
|
+
//
|
|
180
|
+
// A mod that acks sends `got` (the last seq it took) and `ack` (the last seq it submitted to
|
|
181
|
+
// the session). The board answers only lines past `got` and keeps each line until its ack, so
|
|
182
|
+
// a reply lost on the way, or a mod that dies before its submit, gets the line again. A mod
|
|
183
|
+
// before acks sends neither, and a line is gone once a reply carries it.
|
|
184
|
+
async function handleSessionEvents(req, res) {
|
|
105
185
|
const topic = req.query.topic;
|
|
106
186
|
if (topic !== undefined && !(typeof topic === 'string' && TOPIC_RE.test(topic))) {
|
|
107
187
|
return res.status(400).json({ error: 'invalid topic' });
|
|
108
188
|
}
|
|
109
189
|
const sessionId = topicKey(topic, req.params.sessionId);
|
|
110
|
-
const bucket = sessionEventBuckets.get(sessionId);
|
|
111
190
|
const wait = clampWait(req.query.wait);
|
|
191
|
+
const acks = req.query.got !== undefined;
|
|
192
|
+
const trusted = acks && req.query.board === BOARD_RUN;
|
|
193
|
+
const got = trusted ? seqParam(req.query.got) : 0;
|
|
194
|
+
const ack = trusted ? seqParam(req.query.ack) : 0;
|
|
195
|
+
|
|
196
|
+
if (!topic) {
|
|
197
|
+
aliases.delete(sessionId);
|
|
198
|
+
const known = doorbells.has(sessionId);
|
|
199
|
+
remember(doorbells, sessionId, true);
|
|
200
|
+
if (req.query.first === '1' && typeof req.query.prev === 'string') adopt(req.query.prev, sessionId);
|
|
201
|
+
if (!known && hooks.restore) await restorePending(sessionId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const bucket = sessionEventBuckets.get(sessionId);
|
|
205
|
+
if (bucket && ack) {
|
|
206
|
+
const [done, rest] = partition(bucket.queue, (e) => e.seq <= ack);
|
|
207
|
+
bucket.queue = rest;
|
|
208
|
+
delivered(done);
|
|
209
|
+
}
|
|
112
210
|
|
|
113
211
|
// A doorbell can attach long after the board moved something (a resumed session, a board
|
|
114
212
|
// that came up later). Those lines are read as instructions, and an hours-old instruction
|
|
115
|
-
// is worse than no instruction, so `first=1` drops the
|
|
116
|
-
//
|
|
117
|
-
|
|
213
|
+
// is worse than no instruction, so `first=1` drops the backlog of moves, and an emptied
|
|
214
|
+
// bucket is still evicted from the map. Reviews are kept: the review file says what to
|
|
215
|
+
// address however late it arrives.
|
|
216
|
+
if (bucket && req.query.first === '1') bucket.queue = bucket.queue.filter((e) => e.marker);
|
|
217
|
+
|
|
218
|
+
const take = (b) => {
|
|
219
|
+
if (acks) return b.queue.filter((e) => e.seq > got);
|
|
220
|
+
const all = b.queue.splice(0);
|
|
221
|
+
delivered(all);
|
|
222
|
+
return all;
|
|
223
|
+
};
|
|
224
|
+
const reply = (entries) => ({ events: entries.map((e) => e.text), seqs: entries.map((e) => e.seq), board: BOARD_RUN });
|
|
118
225
|
|
|
119
|
-
|
|
120
|
-
if (
|
|
226
|
+
const ready = bucket ? take(bucket) : [];
|
|
227
|
+
if (bucket) evictIfEmpty(sessionId, bucket);
|
|
228
|
+
if (ready.length) return res.json(reply(ready));
|
|
229
|
+
if (!wait) return res.json(reply([]));
|
|
121
230
|
|
|
122
|
-
const pending =
|
|
123
|
-
sessionEventBuckets.set(sessionId, pending);
|
|
231
|
+
const pending = bucketFor(sessionId);
|
|
124
232
|
|
|
125
|
-
|
|
233
|
+
// A closed request has nobody to read the answer, so its lines stay queued.
|
|
234
|
+
const send = (closed) => {
|
|
126
235
|
// Set.delete is the whole idempotency story: whichever of enqueue, timeout, or
|
|
127
236
|
// client disconnect gets here first is the one that answers.
|
|
128
237
|
if (!pending.waiters.delete(send)) return;
|
|
129
238
|
clearTimeout(timer);
|
|
130
|
-
req.removeListener('close',
|
|
131
|
-
const
|
|
132
|
-
|
|
239
|
+
req.removeListener('close', onClose);
|
|
240
|
+
const entries = closed === true || res.writableEnded ? [] : take(pending);
|
|
241
|
+
evictIfEmpty(sessionId, pending);
|
|
242
|
+
if (!res.writableEnded) res.json(reply(entries));
|
|
133
243
|
};
|
|
244
|
+
const onClose = () => send(true);
|
|
134
245
|
const timer = setTimeout(send, wait * 1000);
|
|
135
246
|
pending.waiters.add(send);
|
|
136
|
-
req.on('close',
|
|
247
|
+
req.on('close', onClose);
|
|
137
248
|
}
|
|
138
249
|
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
250
|
+
function evictIfEmpty(sessionId, bucket) {
|
|
251
|
+
if (!bucket.queue.length && !bucket.waiters.size && sessionEventBuckets.get(sessionId) === bucket) {
|
|
252
|
+
sessionEventBuckets.delete(sessionId);
|
|
253
|
+
}
|
|
143
254
|
}
|
|
144
255
|
|
|
145
256
|
module.exports = {
|
|
@@ -149,7 +260,8 @@ module.exports = {
|
|
|
149
260
|
formatTaskMoved,
|
|
150
261
|
boardEventsOn,
|
|
151
262
|
formatReviewSubmitted,
|
|
152
|
-
|
|
263
|
+
hasDoorbell,
|
|
264
|
+
configureSessionEvents,
|
|
153
265
|
enqueueSessionEvent,
|
|
154
266
|
handleSessionEvents,
|
|
155
267
|
topicKey,
|