bothouse-dsh 0.1.0 → 0.1.41
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/client/client.js +18267 -648
- package/collab/approvals.mjs +177 -0
- package/collab/attachments.mjs +144 -0
- package/collab/avatar-uploads.mjs +53 -0
- package/collab/avatars.mjs +1 -1
- package/collab/cli-member.mjs +117 -32
- package/collab/cloudweb.mjs +173 -114
- package/collab/dsh-member.mjs +588 -44
- package/collab/gate-context.mjs +80 -0
- package/collab/handoff-rules.mjs +122 -0
- package/collab/hub.mjs +23 -4
- package/collab/later.mjs +146 -0
- package/collab/memory-index.mjs +61 -0
- package/collab/mentions.mjs +8 -0
- package/collab/page.html +24 -3
- package/collab/personas.mjs +4 -0
- package/collab/plugin.mjs +385 -32
- package/collab/quota-cooling.mjs +169 -0
- package/collab/roster.mjs +81 -16
- package/collab/store.mjs +220 -45
- package/collab/tasks.mjs +646 -38
- package/collab/team-exit.mjs +189 -11
- package/collab/verify-multichannel.mjs +42 -0
- package/package.json +13 -3
package/collab/tasks.mjs
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// waker can reach the owner (cloud-web bot offline, or not reconnected yet after
|
|
9
9
|
// a restart) the reminder stays pending and is delivered once when it comes online.
|
|
10
10
|
import { CollabError, MAX_TEXT } from './store.mjs';
|
|
11
|
+
import { mentions, nameCalled, split, withoutLeadingMentions } from './mentions.mjs';
|
|
12
|
+
import { NOTICE_PREFIXES } from './cloudweb.mjs';
|
|
13
|
+
import { exitMark } from './team-exit.mjs';
|
|
14
|
+
import { checkDispatch, HandoffRulesError } from './handoff-rules.mjs';
|
|
11
15
|
|
|
12
16
|
/** Status codes and their labels (the eight names are a #139 B setting, not user-specified). */
|
|
13
17
|
export const STATUS = {
|
|
@@ -21,23 +25,74 @@ const STANCE_WORDS = { accept: '接', ask: '问', decline: '退', escalate: '升
|
|
|
21
25
|
export const WORKING = ['assigned', 'accepted', 'asked', 'rework'];
|
|
22
26
|
/** Waiting for the lead: review may close or send back. */
|
|
23
27
|
const REVIEWABLE = ['returned', 'declined', 'escalated'];
|
|
28
|
+
/** The owner delivered: handed back, or the lead accepted it (fromChat reads the next call as an answer). */
|
|
29
|
+
const DELIVERED = ['returned', 'done'];
|
|
30
|
+
// #597 r12: how long a bare-name call after a delivery waits for the owner's stance (Tasks.pendingCalls).
|
|
31
|
+
// Source: 聊天频道橘 10-26「超过 30 分钟没回也丢弃」.
|
|
32
|
+
const PENDING_CALL_S = 30 * 60;
|
|
24
33
|
// Default check time after dispatch or rework. Source: user 2026-10-23「这10分钟之内搞不完就滚蛋」.
|
|
25
34
|
export const CHECK_AFTER_S = 10 * 60;
|
|
35
|
+
// Workspace watch (#574 B2, same rule as contrib/lead/d10-fire MAX_RENEW, 0 = no cap): at the
|
|
36
|
+
// check time a workspace task with new role messages since its window opened gets another
|
|
37
|
+
// CHECK_AFTER_S, renewing while there is progress; otherwise it is parked (paused queue).
|
|
38
|
+
// Replaces the old one-hour park. Source: user 2026-10-26 agreed「有进展就一直续,没进展才停」(#429),
|
|
39
|
+
// replacing 橘 10-25 15:19「最多续 2 次」; same as Go watchMaxRenew.
|
|
40
|
+
export const MAX_RENEWALS = 0;
|
|
26
41
|
// setTimeout's own maximum delay (Node clamps anything larger to 1 ms).
|
|
27
42
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
28
43
|
const STANCE_LINE = /^\s*【(接|问|退|升)】\s*(?:#(\d+))?\s*(.*)$/;
|
|
44
|
+
// The dispatcher's explicit review of a workspace task, a line of its chat-channel reply:
|
|
45
|
+
// 【验收】#id closes it, 【返工】#id 原因 sends it back (Tasks.verdictsOf).
|
|
46
|
+
const VERDICT_LINE = /^[ \t]*【(验收|返工)】[ \t]*#(\d+)[ \t]*(.*)$/gm;
|
|
29
47
|
const HUMAN = 'owner';
|
|
48
|
+
// #597 G35 briefing sizes (chars): enough for the handoff's goal and acceptance lines and the
|
|
49
|
+
// report's summary, short of MAX_TEXT with the record around them. Not user-specified.
|
|
50
|
+
const BRIEF_HANDOFF = 1500;
|
|
51
|
+
const BRIEF_REPORT = 800;
|
|
30
52
|
const PLACEHOLDERS = WORKING.map(() => '?').join(', ');
|
|
53
|
+
// A paused task (park: the bridge released its workspace with the task stopped at a clean
|
|
54
|
+
// point) keeps its status but is not in progress: no check timer, not counted. Any status
|
|
55
|
+
// change by its owner or the lead (stance, hand-back, review) resumes it.
|
|
56
|
+
const LIVE = `status IN (${PLACEHOLDERS}) AND paused_at IS NULL`;
|
|
57
|
+
// #574 dispatch fields: `ref` names the existing implementation to follow, `base` the branch to start
|
|
58
|
+
// from when several members edit the same files. The checks and their messages are in the shared
|
|
59
|
+
// handoff rules (handoff-rules.json, also read by ws-handoff); a dispatch runs the ones listed under
|
|
60
|
+
// its `dispatch` key. The rules file not being readable refuses the dispatch (HTTP 500), never skips it.
|
|
61
|
+
const MISSING_REWORK_NOTE = '返工要写原因:note 填为什么打回,示例:note: "没先读现有实现"';
|
|
62
|
+
|
|
63
|
+
/** Runs a handoff check; a missing or broken rules file becomes a CollabError instead of letting it through. */
|
|
64
|
+
export function withRules(fn) {
|
|
65
|
+
try {
|
|
66
|
+
return fn();
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e instanceof HandoffRulesError) throw new CollabError('rules-missing', e.message);
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when ref passes the shared reference-implementation check on its own. */
|
|
74
|
+
export function hasRef(ref) { return checkDispatch({ title: '', ref }) === null; }
|
|
31
75
|
|
|
32
76
|
export class Tasks {
|
|
77
|
+
reassignForQuota(task, owner) {
|
|
78
|
+
const row = this.db.prepare(`UPDATE tasks SET owner = ?, status = 'assigned', next_check_at = ?,
|
|
79
|
+
renewals = 0, remind_msg_id = NULL, updated_at = ? WHERE id = ? AND owner = ? RETURNING *`)
|
|
80
|
+
.get(owner, this.now() + CHECK_AFTER_S, this.now(), task.id, task.owner);
|
|
81
|
+
if (row) {
|
|
82
|
+
this.record(row, 'quota', `#${row.id}「${row.title}」额度冷却转交 → ${this.nameOf(owner)}。原交接:${task.reason ?? ''}\n参照:${task.ref ?? ''}\n底分支:${task.base ?? ''}`);
|
|
83
|
+
this.changed(row.id);
|
|
84
|
+
}
|
|
85
|
+
return row;
|
|
86
|
+
}
|
|
33
87
|
/**
|
|
34
88
|
* @param hub - Hub (store, members, post, emit).
|
|
35
89
|
* @param options - { now?(): seconds, setTimer?, clearTimer? } (tests drive the clock);
|
|
36
90
|
* start: false defers the timer and pending deliveries until start() (the plugin
|
|
37
91
|
* starts after DSH recovery, so a reminder job is never mistaken for a leftover).
|
|
38
92
|
*/
|
|
39
|
-
constructor(hub, { now = () => Date.now() / 1000, setTimer = setTimeout, clearTimer = clearTimeout, start = true } = {}) {
|
|
93
|
+
constructor(hub, { now = () => Date.now() / 1000, setTimer = setTimeout, clearTimer = clearTimeout, start = true, maxRenewals = MAX_RENEWALS } = {}) {
|
|
40
94
|
this.hub = hub;
|
|
95
|
+
this.maxRenewals = maxRenewals;
|
|
41
96
|
this.store = hub.store;
|
|
42
97
|
this.db = hub.store.db;
|
|
43
98
|
this.now = now;
|
|
@@ -50,37 +105,66 @@ export class Tasks {
|
|
|
50
105
|
this.wakers = [];
|
|
51
106
|
/** released(roomId) -> a workspace finished its last task and went back to the pool. */
|
|
52
107
|
this.releasers = [];
|
|
108
|
+
/**
|
|
109
|
+
* #597 r12: bare-name calls after a delivery, waiting for the owner's stance line, keyed
|
|
110
|
+
* `room\0owner`: { roomId, owner, lead, title, messageId, at }. In memory only: a restart drops them.
|
|
111
|
+
*/
|
|
112
|
+
this.pendingCalls = new Map();
|
|
53
113
|
const cols = new Set(this.db.prepare('PRAGMA table_info(rooms)').all().map(c => c.name));
|
|
54
114
|
// rooms.kind/parent_id come with team mode A (#134); without them every room is its own chat channel.
|
|
55
115
|
this.tree = cols.has('parent_id') && cols.has('kind');
|
|
56
116
|
this.q = {
|
|
57
117
|
get: this.db.prepare('SELECT * FROM tasks WHERE id = ?'),
|
|
58
|
-
add: this.db.prepare(`INSERT INTO tasks (room_id, parent_task, title, owner, created_by, status, next_check_at, assign_msg_id, created_at, updated_at, reason)
|
|
59
|
-
VALUES (?, ?, ?, ?, ?, 'assigned', ?, ?, ?, ?, ?) RETURNING *`),
|
|
118
|
+
add: this.db.prepare(`INSERT INTO tasks (room_id, parent_task, title, owner, created_by, status, next_check_at, assign_msg_id, created_at, updated_at, reason, ref, base)
|
|
119
|
+
VALUES (?, ?, ?, ?, ?, 'assigned', ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
|
|
60
120
|
// The last ? keeps (1) or drops (0) an undelivered reminder: only 接/问 stay the owner's turn.
|
|
61
|
-
|
|
62
|
-
|
|
121
|
+
// A resumed (was paused) task starts a fresh watch: renewals back to 0 (rework: resetRenewals).
|
|
122
|
+
set: this.db.prepare(`UPDATE tasks SET status = ?, paused_at = NULL, next_check_at = ?, note = ?, updated_at = ?, remind_msg_id = CASE WHEN ? THEN remind_msg_id END,
|
|
123
|
+
renewals = CASE WHEN paused_at IS NOT NULL THEN 0 ELSE renewals END WHERE id = ? AND status = ? RETURNING *`),
|
|
124
|
+
handBack: this.db.prepare(`UPDATE tasks SET status = 'returned', paused_at = NULL, next_check_at = NULL, remind_msg_id = NULL, result_msg_id = ?, returned_at = ?, updated_at = ?
|
|
63
125
|
WHERE id = ? AND status = ? RETURNING *`),
|
|
64
126
|
bindAssign: this.db.prepare('UPDATE tasks SET assign_msg_id = ? WHERE id = ? AND assign_msg_id IS NULL'),
|
|
65
127
|
due: this.db.prepare(`SELECT * FROM tasks WHERE next_check_at IS NOT NULL AND next_check_at <= ?
|
|
66
|
-
AND
|
|
128
|
+
AND ${LIVE} ORDER BY next_check_at, id`),
|
|
67
129
|
// Posting the reminder and marking it undelivered is one step; the check time is spent.
|
|
68
130
|
unremind: this.db.prepare('UPDATE tasks SET next_check_at = NULL, remind_msg_id = ? WHERE id = ? AND next_check_at IS NOT NULL RETURNING *'),
|
|
69
|
-
undelivered: this.db.prepare(`SELECT * FROM tasks WHERE remind_msg_id IS NOT NULL AND
|
|
131
|
+
undelivered: this.db.prepare(`SELECT * FROM tasks WHERE remind_msg_id IS NOT NULL AND ${LIVE} ORDER BY id`),
|
|
70
132
|
// Clear before waking (a success is never re-sent); restore only if nobody took it.
|
|
71
133
|
takeRemind: this.db.prepare('UPDATE tasks SET remind_msg_id = NULL WHERE id = ? AND remind_msg_id = ? RETURNING *'),
|
|
72
134
|
putRemind: this.db.prepare('UPDATE tasks SET remind_msg_id = ? WHERE id = ? AND remind_msg_id IS NULL'),
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
135
|
+
active: this.db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE room_id = ? AND ${LIVE}`),
|
|
136
|
+
paused: this.db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE room_id = ? AND status IN (${PLACEHOLDERS}) AND paused_at IS NOT NULL`),
|
|
137
|
+
// Rows the owner posted or changed in place since a time (changed_at: a streamed reply growing, a progress card edited).
|
|
138
|
+
ownerSaid: this.db.prepare(`SELECT text, card FROM messages WHERE room_id = ? AND author_kind = 'role' AND author = ? AND deleted = 0
|
|
139
|
+
AND COALESCE(changed_at, created_at) > ?`),
|
|
140
|
+
// paused_chat: the chat channel that owned the workspace when the task was paused (resumeClaim).
|
|
141
|
+
resetRenewals: this.db.prepare('UPDATE tasks SET renewals = 0 WHERE id = ?'),
|
|
142
|
+
renew: this.db.prepare(`UPDATE tasks SET next_check_at = ?, renewals = ? WHERE id = ? AND ${LIVE} RETURNING *`),
|
|
143
|
+
park: this.db.prepare(`UPDATE tasks SET paused_at = ?, paused_chat = ?, next_check_at = NULL WHERE room_id = ? AND ${LIVE}`),
|
|
144
|
+
open: this.db.prepare(`SELECT * FROM tasks WHERE status IN (${PLACEHOLDERS}) ORDER BY id`),
|
|
145
|
+
autoClose: this.db.prepare(`UPDATE tasks SET status = 'returned', next_check_at = NULL, remind_msg_id = NULL,
|
|
146
|
+
note = ?, returned_at = ?, updated_at = ? WHERE id = ? AND status IN (${PLACEHOLDERS}) RETURNING *`),
|
|
147
|
+
busyOfOwner: this.db.prepare(`SELECT * FROM tasks WHERE owner = ? AND ${LIVE} ORDER BY updated_at DESC`),
|
|
148
|
+
allBusy: this.db.prepare(`SELECT * FROM tasks WHERE ${LIVE} ORDER BY updated_at DESC`),
|
|
77
149
|
// A stance line without #id only answers a dispatch the owner has not answered yet.
|
|
78
150
|
latestAssigned: this.db.prepare("SELECT * FROM tasks WHERE room_id = ? AND owner = ? AND status = 'assigned' ORDER BY id DESC LIMIT 1"),
|
|
151
|
+
// The owner's newest task in a room, whatever its status (fromChat: did she just deliver?).
|
|
152
|
+
latestOf: this.db.prepare('SELECT * FROM tasks WHERE room_id = ? AND owner = ? ORDER BY id DESC LIMIT 1'),
|
|
153
|
+
// Chat-side tasks (source 'mention' / 'card'): opened from the lead's @ or the bridge task card.
|
|
154
|
+
setSource: this.db.prepare('UPDATE tasks SET source = ? WHERE id = ?'),
|
|
155
|
+
openChat: this.db.prepare(`SELECT * FROM tasks WHERE room_id = ? AND owner = ? AND created_by = ? AND source IS NOT NULL
|
|
156
|
+
AND status IN (${PLACEHOLDERS}) ORDER BY id DESC LIMIT 1`),
|
|
157
|
+
returnedChat: this.db.prepare("SELECT * FROM tasks WHERE room_id = ? AND created_by = ? AND source IN ('mention', 'card') AND status = 'returned' ORDER BY id"),
|
|
79
158
|
children: this.tree ? this.db.prepare("SELECT id FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id") : undefined,
|
|
159
|
+
// #597 G24: a job the lead already handed to one of this chat channel's workspaces.
|
|
160
|
+
openHanded: this.tree ? this.db.prepare(`SELECT * FROM tasks WHERE owner = ? AND created_by = ? AND source = 'workspace'
|
|
161
|
+
AND status IN (${PLACEHOLDERS}) AND room_id IN (SELECT id FROM rooms WHERE kind = 'workspace' AND parent_id = ?) ORDER BY id DESC LIMIT 1`) : undefined,
|
|
80
162
|
};
|
|
81
163
|
hub.onMessage(message => this.fromMessage(message));
|
|
82
164
|
// A member coming online (cloud-web register, DSH member published) gets its pending reminders.
|
|
83
|
-
this.unsubscribe = hub.subscribe(event => {
|
|
165
|
+
this.unsubscribe = hub.subscribe(event => {
|
|
166
|
+
if (event.type === 'members') { this.deliverPending(); this.arm(); }
|
|
167
|
+
});
|
|
84
168
|
if (start) this.start();
|
|
85
169
|
}
|
|
86
170
|
|
|
@@ -119,20 +203,28 @@ export class Tasks {
|
|
|
119
203
|
const chat = this.chatOf(roomId);
|
|
120
204
|
if (chat === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
|
|
121
205
|
const rooms = [chat, ...(this.tree ? this.q.children.all(chat).map(r => r.id) : [])];
|
|
122
|
-
|
|
206
|
+
// A paused task stays on the board of the chat channel it was paused under (paused_chat), even
|
|
207
|
+
// after its workspace went back to the pool and another chat channel claimed it: a to-do is never lost.
|
|
208
|
+
const rows = this.db.prepare(`SELECT * FROM tasks WHERE (room_id IN (${rooms.map(() => '?').join(', ')})
|
|
209
|
+
AND (paused_at IS NULL OR paused_chat IS NULL OR paused_chat = ?)) OR (paused_at IS NOT NULL AND paused_chat = ?)
|
|
210
|
+
ORDER BY id DESC`).all(...rooms, chat, chat);
|
|
123
211
|
return rows.map(r => this.view(r));
|
|
124
212
|
}
|
|
125
213
|
|
|
126
214
|
/** In-progress tasks in one room (the "进行中 N 件" next to it in the channel list). */
|
|
127
215
|
countActive(roomId) { return this.q.active.get(roomId, ...WORKING).n; }
|
|
128
216
|
|
|
217
|
+
/** Paused tasks in roomId (park): not counted by countActive. */
|
|
218
|
+
countPaused(roomId) { return this.q.paused.get(roomId, ...WORKING).n; }
|
|
219
|
+
|
|
129
220
|
/** Public task row: codes plus display names. */
|
|
130
221
|
view(t) {
|
|
131
222
|
return {
|
|
132
223
|
id: t.id, room_id: t.room_id, parent_task: t.parent_task, title: t.title,
|
|
133
224
|
owner: t.owner, owner_name: this.nameOf(t.owner), created_by: t.created_by, created_by_name: this.nameOf(t.created_by),
|
|
134
225
|
status: t.status, next_check_at: t.next_check_at, assign_msg_id: t.assign_msg_id, result_msg_id: t.result_msg_id,
|
|
135
|
-
note: t.note, reason: t.reason, created_at: t.created_at, updated_at: t.updated_at, returned_at: t.returned_at,
|
|
226
|
+
note: t.note, reason: t.reason, ref: t.ref ?? null, base: t.base ?? null, created_at: t.created_at, updated_at: t.updated_at, returned_at: t.returned_at,
|
|
227
|
+
paused_at: t.paused_at ?? null,
|
|
136
228
|
};
|
|
137
229
|
}
|
|
138
230
|
|
|
@@ -196,16 +288,29 @@ export class Tasks {
|
|
|
196
288
|
* Dispatch: the actor (the lead for this task) assigns `title` in `roomId` to member `owner`.
|
|
197
289
|
* `reason` is the dispatcher's own one-line why-this-member, shown in the record (team mode C).
|
|
198
290
|
* `force` allows assigning even if the member has an in-progress task in any room.
|
|
291
|
+
* `ref`/`base` (#574): an API dispatch passes ref (string, checked by the shared rules) and, for a
|
|
292
|
+
* multi-editor task, base; undefined ref (tasks opened from chat or bridge cards) skips the check.
|
|
199
293
|
*/
|
|
200
|
-
assign({ roomId, title, owner, actor, parentTask, messageId, reason, force = false }) {
|
|
294
|
+
assign({ roomId, title, owner, actor, parentTask, reviewOf, messageId, reason, ref, base, force = false }) {
|
|
201
295
|
const room = this.store.room(roomId);
|
|
202
296
|
if (room === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
|
|
203
297
|
const text = String(title ?? '').trim();
|
|
204
298
|
if (!text || text.length > MAX_TEXT) throw new CollabError('invalid', 'task title is empty or too long');
|
|
205
299
|
this.member(owner);
|
|
300
|
+
if (!this.hub.membersInRoom(roomId).some(m => m.id === owner)) throw new CollabError('member-missing', `member ${owner} does not belong to room ${roomId}`);
|
|
206
301
|
this.member(actor);
|
|
302
|
+
if (reviewOf != null) {
|
|
303
|
+
const original = this.mustTask(reviewOf);
|
|
304
|
+
if (actor !== original.created_by && actor !== HUMAN) throw new CollabError('forbidden', 'only the dispatcher or owner can delegate a review');
|
|
305
|
+
if (owner === original.owner) throw new CollabError('forbidden', 'the author cannot review their own task');
|
|
306
|
+
if (!REVIEWABLE.includes(original.status)) throw new CollabError('conflict', 'only a returned task can be delegated for review');
|
|
307
|
+
if (this.chatOf(original.room_id) !== this.chatOf(roomId)) throw new CollabError('forbidden', 'a review must stay in the task chat channel');
|
|
308
|
+
parentTask = original.id;
|
|
309
|
+
}
|
|
207
310
|
if (parentTask != null && this.task(parentTask) === undefined) throw new CollabError('task-missing', `task ${parentTask} does not exist`);
|
|
208
311
|
if (messageId != null && this.store.message(messageId) === undefined) throw new CollabError('invalid', `message ${messageId} does not exist`);
|
|
312
|
+
const refError = ref === undefined ? null : withRules(() => checkDispatch({ title: text, ref, base }));
|
|
313
|
+
if (refError) throw new CollabError('invalid', refError);
|
|
209
314
|
const excludeIds = parentTask != null ? this.ancestorsOf(parentTask) : new Set();
|
|
210
315
|
const busy = this.busyInfo(owner, excludeIds);
|
|
211
316
|
if (busy.busy && !force) {
|
|
@@ -214,9 +319,17 @@ export class Tasks {
|
|
|
214
319
|
}
|
|
215
320
|
const now = this.now();
|
|
216
321
|
const why = clip(reason);
|
|
217
|
-
const
|
|
322
|
+
const refText = ref === undefined ? null : clip(ref);
|
|
323
|
+
const baseText = ref === undefined ? null : clip(base);
|
|
324
|
+
const task = this.q.add.get(roomId, parentTask ?? null, text, owner, actor, now + CHECK_AFTER_S, messageId ?? null, now, now, why, refText, baseText);
|
|
325
|
+
if (reviewOf != null) this.q.setSource.run('review', task.id);
|
|
326
|
+
const refNote = `${refText ? `,参照:${refText}` : ''}${baseText ? `,底分支:${baseText}` : ''}`;
|
|
218
327
|
const busyNote = busy.busy ? `(注:该成员在「${busy.roomName}」已有进行中任务 #${busy.task.id})` : '';
|
|
219
|
-
|
|
328
|
+
// The task card shows the title, so the record does not repeat it. A task opened from a message
|
|
329
|
+
// (an @ dispatch, a handoff) gets its card under that message on the page (TaskCard by
|
|
330
|
+
// assign_msg_id), so no record goes to the message's own room: the dispatch text shows once.
|
|
331
|
+
const source = messageId == null ? undefined : this.store.message(messageId).room_id;
|
|
332
|
+
const record = this.record(task, 'assign', `派活 #${task.id} → ${this.nameOf(owner)}(${this.nameOf(actor)} 派)${why ? `,理由:${why}` : ''}${refNote}${busyNote ? ` ${busyNote}` : ''}`, source);
|
|
220
333
|
if (messageId == null) this.q.bindAssign.run(record.id, task.id);
|
|
221
334
|
return this.changed(task.id);
|
|
222
335
|
}
|
|
@@ -229,8 +342,11 @@ export class Tasks {
|
|
|
229
342
|
if (actor !== task.owner && actor !== HUMAN) throw new CollabError('forbidden', `only ${this.nameOf(task.owner)} can answer task ${id}`);
|
|
230
343
|
if (!WORKING.includes(task.status)) throw new CollabError('conflict', `task ${id} is ${STATUS[task.status]}, not waiting for its owner`);
|
|
231
344
|
const status = STANCES[mark];
|
|
345
|
+
this.resumeClaim(task, true);
|
|
232
346
|
// 退/升 hand the turn to the lead: the reminder is cancelled.
|
|
233
|
-
|
|
347
|
+
// A paused task answered again is back in work with a fresh check time.
|
|
348
|
+
const check = status === 'declined' || status === 'escalated' ? null
|
|
349
|
+
: task.paused_at != null ? this.now() + CHECK_AFTER_S : task.next_check_at;
|
|
234
350
|
const row = this.q.set.get(status, check, clip(note), this.now(), check === null ? 0 : 1, id, task.status);
|
|
235
351
|
if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
|
|
236
352
|
const tail = row.note ? `:${row.note}` : '';
|
|
@@ -244,32 +360,88 @@ export class Tasks {
|
|
|
244
360
|
if (actor !== task.owner && actor !== HUMAN) throw new CollabError('forbidden', `only ${this.nameOf(task.owner)} can hand back task ${id}`);
|
|
245
361
|
if (!WORKING.includes(task.status)) throw new CollabError('conflict', `task ${id} is ${STATUS[task.status]}, nothing to hand back`);
|
|
246
362
|
if (messageId == null || this.store.message(messageId) === undefined) throw new CollabError('invalid', 'message_id must name the delivery message');
|
|
363
|
+
this.resumeClaim(task, false);
|
|
247
364
|
const now = this.now();
|
|
248
365
|
const row = this.q.handBack.get(messageId, now, now, id, task.status);
|
|
249
366
|
if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
|
|
250
367
|
this.record(row, 'return', `#${id}「${row.title}」${this.nameOf(row.owner)} 交回,用时 ${duration(row.returned_at - row.created_at)}`);
|
|
368
|
+
this.returnToChat(row, messageId);
|
|
251
369
|
this.releaseIfDone(row.room_id);
|
|
252
370
|
return this.changed(id);
|
|
253
371
|
}
|
|
254
372
|
|
|
373
|
+
/**
|
|
374
|
+
* A workspace task handed back: its result goes to the chat channel (TeamExit.forwardResult:
|
|
375
|
+
* the [report] body, or the delivery itself) and its dispatcher is woken there on it to review,
|
|
376
|
+
* as a chat-channel hand-back @-s the lead (lead_return.go). A bridge-claimed workspace is the
|
|
377
|
+
* bridge's (its lead session reads the workspace itself).
|
|
378
|
+
*/
|
|
379
|
+
returnToChat(task, messageId) {
|
|
380
|
+
if (!this.tree || this.store.room(task.room_id)?.kind !== 'workspace' || this.store.bridgeClaimed(task.room_id)) return;
|
|
381
|
+
const msg = this.store.message(messageId);
|
|
382
|
+
let copy = msg === undefined ? undefined : this.hub.teamExit?.forwardResult(msg);
|
|
383
|
+
if (copy === undefined || task.created_by === HUMAN) return;
|
|
384
|
+
// The dispatcher is woken on this copy alone (a turn reads no other system row), so it says
|
|
385
|
+
// how to review, as the pause record says how to answer (watch).
|
|
386
|
+
const how = `\n\n验收 #${task.id}:回复里单独一行写【验收】#${task.id};要返工就单独一行写【返工】#${task.id} 原因。`;
|
|
387
|
+
if (!copy.text.includes(how)) copy = this.hub.edit(copy.id, 'system', 'system', copy.text + how, copy.card) ?? copy;
|
|
388
|
+
this.wake(task.created_by, copy);
|
|
389
|
+
}
|
|
390
|
+
|
|
255
391
|
/** The lead (or the human owner) closes the task or sends it back for rework. */
|
|
256
|
-
review(id, { actor, verdict, note }) {
|
|
392
|
+
review(id, { actor, verdict, note, messageId }) {
|
|
257
393
|
const task = this.mustTask(id);
|
|
258
|
-
|
|
394
|
+
const reviews = this.db.prepare(`SELECT * FROM tasks WHERE parent_task = ? AND source = 'review'
|
|
395
|
+
AND status IN (${PLACEHOLDERS})`).all(id, ...WORKING);
|
|
396
|
+
const delegated = actor !== task.owner && reviews.some(r => r.owner === actor && r.paused_at == null);
|
|
397
|
+
if (actor !== task.created_by && actor !== HUMAN && !delegated) throw new CollabError('forbidden', `only ${this.nameOf(task.created_by)} or their delegated reviewer can review task ${id}`);
|
|
259
398
|
if (!REVIEWABLE.includes(task.status)) throw new CollabError('conflict', `task ${id} is ${STATUS[task.status]}, nothing to review`);
|
|
260
399
|
if (verdict !== 'done' && verdict !== 'rework') throw new CollabError('invalid', 'verdict must be done or rework');
|
|
400
|
+
// #574: a send-back always says why (same rule as board done: rework count > 0 needs a reason).
|
|
401
|
+
if (verdict === 'rework' && !clip(note)) throw new CollabError('invalid', MISSING_REWORK_NOTE);
|
|
261
402
|
// Rework is a new round with the same check time as a dispatch (source above).
|
|
262
403
|
const check = verdict === 'rework' ? this.now() + CHECK_AFTER_S : null;
|
|
404
|
+
const reclaim = verdict === 'rework' && this.reworkClaim(task);
|
|
263
405
|
const row = this.q.set.get(verdict, check, clip(note) ?? task.note, this.now(), 0, id, task.status);
|
|
264
406
|
if (row === undefined) throw new CollabError('conflict', `task ${id} changed meanwhile`);
|
|
407
|
+
for (const review of reviews) {
|
|
408
|
+
this.db.prepare(`UPDATE tasks SET status = 'done', next_check_at = NULL, remind_msg_id = NULL,
|
|
409
|
+
paused_at = NULL, result_msg_id = ?, returned_at = ?, updated_at = ? WHERE id = ?`)
|
|
410
|
+
.run(messageId ?? null, this.now(), this.now(), review.id);
|
|
411
|
+
this.record(this.task(review.id), 'done', `#${review.id} 验收 #${id} 已结束:${verdict === 'done' ? '通过' : '返工'}`);
|
|
412
|
+
this.changed(review.id);
|
|
413
|
+
this.releaseIfDone(review.room_id);
|
|
414
|
+
}
|
|
415
|
+
if (verdict === 'rework') this.q.resetRenewals.run(id);
|
|
265
416
|
const tail = clip(note) ? `:${clip(note)}` : '';
|
|
266
|
-
|
|
417
|
+
// A workspace task sent back: its owner is woken there on the record (nobody routes it there
|
|
418
|
+
// otherwise), which says how to hand back as the pause record does (watch).
|
|
419
|
+
const how = reclaim ? `。做完了就交回:回复第一行写【接】#${id},下面写 [report] 和结果;没做完就第一行写【问】#${id},说明做到哪一步、卡在哪。${this.briefing(row)}` : '';
|
|
420
|
+
const record = this.record(row, verdict, verdict === 'done'
|
|
267
421
|
? `#${id}「${row.title}」验收完成(${this.nameOf(actor)})`
|
|
268
|
-
:
|
|
422
|
+
: `${reclaim ? `@${this.nameOf(row.owner)} ` : ''}#${id}「${row.title}」返工 → ${this.nameOf(row.owner)}(${this.nameOf(actor)})${tail}${how}`);
|
|
269
423
|
if (verdict === 'done') this.releaseIfDone(row.room_id);
|
|
424
|
+
if (reclaim || (delegated && verdict === 'rework')) this.wake(row.owner, record);
|
|
270
425
|
return this.changed(id);
|
|
271
426
|
}
|
|
272
427
|
|
|
428
|
+
/**
|
|
429
|
+
* A workspace task (not a bridge claim's) about to be sent back: its hand-back may have released
|
|
430
|
+
* the workspace, so it is claimed again for the chat channel above it with this task as the
|
|
431
|
+
* handoff (as resumeClaim does); one another chat channel claimed since refuses the rework with
|
|
432
|
+
* 'workspace-owned'. True when the task is such a workspace task.
|
|
433
|
+
*/
|
|
434
|
+
reworkClaim(task) {
|
|
435
|
+
if (!this.tree || this.store.room(task.room_id)?.kind !== 'workspace' || this.store.bridgeClaimed(task.room_id)) return false;
|
|
436
|
+
const chat = this.chatOf(task.room_id);
|
|
437
|
+
const owner = this.store.workspaceOwner(task.room_id);
|
|
438
|
+
if (owner != null && owner !== chat) {
|
|
439
|
+
throw new CollabError('workspace-owned', `task ${task.id}'s workspace now belongs to room ${owner}; hand it off again to a free workspace`);
|
|
440
|
+
}
|
|
441
|
+
if (owner == null) this.store.claimWorkspace(chat, task.room_id, { handoff: task.id });
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
273
445
|
/**
|
|
274
446
|
* One API action (#139 B) for a sender. `verified` says the actor comes from
|
|
275
447
|
* the connection or session (owner's browser, a cloud-web bot's socket, the
|
|
@@ -289,7 +461,9 @@ export class Tasks {
|
|
|
289
461
|
}
|
|
290
462
|
const optional = v => (v == null || v === '' ? undefined : Number(v));
|
|
291
463
|
switch (action) {
|
|
292
|
-
case 'assign':
|
|
464
|
+
case 'assign':
|
|
465
|
+
if (body.review_of != null && !verified) throw new CollabError('forbidden', 'delegating review needs a verified sender');
|
|
466
|
+
return this.assign({ roomId, title: body.title, owner: String(body.owner ?? ''), actor, parentTask: optional(body.parent_task), reviewOf: optional(body.review_of), messageId: optional(body.message_id), reason: body.reason, ref: String(body.ref ?? ''), base: body.base, force: Boolean(body.force) });
|
|
293
467
|
case 'stance': return this.stance(id, { actor, stance: String(body.stance ?? ''), note: body.note });
|
|
294
468
|
case 'return': return this.handBack(id, { actor, messageId: optional(body.message_id) });
|
|
295
469
|
case 'review': return this.review(id, { actor, verdict: String(body.verdict ?? ''), note: body.note });
|
|
@@ -300,21 +474,289 @@ export class Tasks {
|
|
|
300
474
|
/** A member reply whose first line is 【接】【问】【退】【升】 (optionally #id) answers its task. */
|
|
301
475
|
fromMessage(msg) {
|
|
302
476
|
if (msg.author_kind !== 'role' || msg.card !== null) return undefined;
|
|
303
|
-
|
|
477
|
+
if (this.verdictsOf(msg)) return undefined;
|
|
478
|
+
this.settleCall(msg);
|
|
479
|
+
const stance = this.stanceOf(msg);
|
|
480
|
+
const dsh = this.hub.members.get(msg.author)?.kind === 'dsh';
|
|
481
|
+
const dshWorkspace = dsh && this.inWorkspace(msg.room_id);
|
|
482
|
+
if (dshWorkspace) {
|
|
483
|
+
// #597 G35: a DSH member (local CLI members too, dsh-member.mjs) posts one reply per turn, but a
|
|
484
|
+
// turn ending is not a delivery. In a workspace only a reply marked [report] (team-exit exitMark,
|
|
485
|
+
// first line or the turn's last part) hands its task back; a 【接】 alone (accepted, waiting) and an
|
|
486
|
+
// [ask] (a question, forwarded by TeamExit) leave the task in work with its 10-minute watch.
|
|
487
|
+
// Only the hand-back changes here: the lead's dispatch below (fromChat) goes on as before.
|
|
488
|
+
const mark = exitMark(msg.text, { lastLine: true, team: this.hub.routable() });
|
|
489
|
+
// A stance line naming #id speaks for that task only (gone or done: nothing to hand back).
|
|
490
|
+
const named = STANCE_LINE.exec(withoutLeadingMentions(String(msg.text), this.hub.routable()).split('\n', 1)[0])?.[2] !== undefined;
|
|
491
|
+
const task = stance !== undefined ? this.task(stance.id) : (named ? undefined : this.openOf(msg.room_id, msg.author));
|
|
492
|
+
if (mark?.kind === 'report' && (stance === undefined || stance.status === 'accepted') && task !== undefined && task.source !== 'review' && WORKING.includes(task.status)) {
|
|
493
|
+
// #597 G37: the hand-back does not end it: a lead's [report] @-ing a teammate dispatches too.
|
|
494
|
+
const handed = this.handBack(task.id, { actor: msg.author, messageId: msg.id });
|
|
495
|
+
this.fromChat(msg, { handBack: false });
|
|
496
|
+
return handed;
|
|
497
|
+
}
|
|
498
|
+
} else if (stance?.status === 'accepted' && this.task(stance.id).source !== 'review' && dsh) {
|
|
499
|
+
// In a chat channel a DSH member's 【接】 is still its delivery (no [report] convention there).
|
|
500
|
+
// A bridge bot may keep working after its 【接】, so its stance stays a stance.
|
|
501
|
+
return this.handBack(stance.id, { actor: msg.author, messageId: msg.id });
|
|
502
|
+
}
|
|
503
|
+
if (stance !== undefined) return stance;
|
|
504
|
+
// A stance naming a task of another room speaks about that task: it hands back nothing here either.
|
|
505
|
+
if (this.foreignStance(msg)) return undefined;
|
|
506
|
+
// A 【问】【退】【升】 reply with no task to answer is a question or a pushback, never a hand-back.
|
|
507
|
+
if (/^【(问|退|升)】/.test(withoutLeadingMentions(String(msg.text), this.hub.routableIn(msg.room_id)).trimStart())) return undefined;
|
|
508
|
+
if (NOTICE_PREFIXES.some(p => String(msg.text).trimStart().startsWith(p))) return undefined;
|
|
509
|
+
return this.fromChat(msg, { handBack: !dshWorkspace });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Lines 【验收】#id / 【返工】#id 原因 in a chat-channel reply review the workspace tasks they
|
|
514
|
+
* name: only the task's dispatcher, only for a workspace under this chat channel. Anything
|
|
515
|
+
* else the dispatcher says (a reply to someone else, a send-back without the line) changes nothing.
|
|
516
|
+
*/
|
|
517
|
+
verdictsOf(msg) {
|
|
518
|
+
let applied = false;
|
|
519
|
+
for (const [, word, id, note] of String(msg.text).matchAll(VERDICT_LINE)) {
|
|
520
|
+
const task = this.task(Number(id));
|
|
521
|
+
if (task === undefined || this.chatOf(task.room_id) !== this.chatOf(msg.room_id)) continue;
|
|
522
|
+
// A dispatcher teaching an independent reviewer the verdict syntax is delegating,
|
|
523
|
+
// even when those example commands occupy their own lines.
|
|
524
|
+
if (task.created_by === msg.author) {
|
|
525
|
+
const team = this.hub.routableIn(msg.room_id);
|
|
526
|
+
const explicit = mentions(String(msg.text), team);
|
|
527
|
+
if (team.some(m => m.id !== msg.author && m.id !== task.owner
|
|
528
|
+
&& (explicit.has(m.id) || nameCalled(String(msg.text), m.name, team.map(t => t.name))))) continue;
|
|
529
|
+
}
|
|
530
|
+
// Dispatchers review in the chat channel; delegated reviewers may work in their review workspace.
|
|
531
|
+
if (this.inWorkspace(msg.room_id) && !this.db.prepare(`SELECT id FROM tasks WHERE parent_task = ?
|
|
532
|
+
AND source = 'review' AND room_id = ? AND owner = ? AND ${LIVE}`).get(task.id, msg.room_id, msg.author, ...WORKING)) continue;
|
|
533
|
+
try {
|
|
534
|
+
this.review(task.id, { actor: msg.author, verdict: word === '验收' ? 'done' : 'rework', note: word === '返工' ? note.trim() : undefined, messageId: msg.id });
|
|
535
|
+
applied = true;
|
|
536
|
+
} catch (error) {
|
|
537
|
+
this.hub.logger?.warn?.('collab: %s #%s from %s refused: %s', word, id, msg.author, error?.message ?? error);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return applied;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* #597 G35: a DSH owner woken (rework, pause) with no session left in the task's workspace (a new
|
|
545
|
+
* claim cleared it, store.resetSessions) starts with nothing but the wake: it gets the original
|
|
546
|
+
* handoff and its last [report], so it never reports on work it cannot see. '' while its session
|
|
547
|
+
* is there (it goes on in it) and for other members.
|
|
548
|
+
*/
|
|
549
|
+
briefing(task) {
|
|
550
|
+
if (this.hub.members.get(task.owner)?.kind !== 'dsh' || this.store.binding(task.owner, task.room_id) !== undefined) return '';
|
|
551
|
+
const handoff = task.assign_msg_id == null ? undefined : this.store.message(task.assign_msg_id);
|
|
552
|
+
const result = task.result_msg_id == null ? undefined : this.store.message(task.result_msg_id);
|
|
553
|
+
const report = result === undefined ? '' : (exitMark(result.text, { lastLine: true, team: this.hub.routable() })?.body ?? String(result.text));
|
|
554
|
+
const cut = (t, n) => { const v = String(t ?? '').trim(); return v.length > n ? `${v.slice(0, n)}…` : v; };
|
|
555
|
+
const parts = [];
|
|
556
|
+
if (handoff !== undefined) parts.push(`原交接:\n${cut(handoff.text, BRIEF_HANDOFF)}`);
|
|
557
|
+
if (report.trim()) parts.push(`上次 [report] 摘录:\n${cut(report, BRIEF_REPORT)}`);
|
|
558
|
+
return parts.length ? `\n\n这是新会话,接着做要的上下文:\n${parts.join('\n\n')}` : '';
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** True when roomId is a workspace (team mode A). */
|
|
562
|
+
inWorkspace(roomId) { return this.tree && this.store.room(roomId)?.kind === 'workspace'; }
|
|
563
|
+
|
|
564
|
+
/** owner's latest task in work in roomId (not paused-only: a paused one is resumed by handBack). */
|
|
565
|
+
openOf(roomId, owner) {
|
|
566
|
+
return this.db.prepare(`SELECT * FROM tasks WHERE room_id = ? AND owner = ? AND status IN (${PLACEHOLDERS}) ORDER BY id DESC LIMIT 1`)
|
|
567
|
+
.get(roomId, owner, ...WORKING);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The first line is a stance whose #id names a task in another room. */
|
|
571
|
+
foreignStance(msg) {
|
|
572
|
+
const m = STANCE_LINE.exec(withoutLeadingMentions(String(msg.text), this.hub.routable()).split('\n', 1)[0]);
|
|
573
|
+
const task = m?.[2] !== undefined ? this.task(Number(m[2])) : undefined;
|
|
574
|
+
return task !== undefined && task.room_id !== msg.room_id;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
stanceOf(msg) {
|
|
578
|
+
// A bridge final reply opens with the lead's @ (lead_return.go), so skip leading mentions.
|
|
579
|
+
const first = withoutLeadingMentions(String(msg.text), this.hub.routableIn(msg.room_id)).split('\n', 1)[0];
|
|
304
580
|
const m = STANCE_LINE.exec(first);
|
|
305
581
|
if (m === null) return undefined;
|
|
306
582
|
// Members open every reply with a stance, so without #id only a not-yet-answered
|
|
307
583
|
// dispatch counts; once answered, changing that task needs its #id.
|
|
308
584
|
const task = m[2] !== undefined ? this.task(Number(m[2]))
|
|
309
585
|
: this.q.latestAssigned.get(msg.room_id, msg.author);
|
|
310
|
-
|
|
586
|
+
// A task is answered in its own room: a 【接】#id in the chat channel about a workspace task is
|
|
587
|
+
// talk about it (and for a DSH member would be a hand-back without the work), not its answer.
|
|
588
|
+
if (task === undefined || task.room_id !== msg.room_id || task.owner !== msg.author || !WORKING.includes(task.status)) return undefined;
|
|
311
589
|
return this.stance(task.id, { actor: msg.author, stance: m[1], note: m[3] });
|
|
312
590
|
}
|
|
313
591
|
|
|
314
|
-
/**
|
|
592
|
+
/**
|
|
593
|
+
* The board follows the channel the way Discord's task card follows a lead's dispatch,
|
|
594
|
+
* without the task API: the primary role @-ing (or calling by leading name) teammates
|
|
595
|
+
* opens one task per teammate that has none open from it in this room; a teammate's
|
|
596
|
+
* reply that @-s the dispatcher (cloud-web lead_return adds it to every final reply)
|
|
597
|
+
* hands its open task back; the dispatcher's next message accepts what was handed back here
|
|
598
|
+
* (a workspace task needs its 【验收】#id line, verdictsOf).
|
|
599
|
+
*/
|
|
600
|
+
fromChat(msg, { handBack = true } = {}) {
|
|
601
|
+
const lead = this.store.leadId(msg.room_id) === null ? this.hub.primaryMember() : this.hub.primaryIn(msg.room_id);
|
|
602
|
+
const team = this.hub.routableIn(msg.room_id);
|
|
603
|
+
const text = String(msg.text);
|
|
604
|
+
if (lead !== undefined && msg.author === lead.id) {
|
|
605
|
+
const reviewIds = new Set([...text.matchAll(/【(?:验收|返工)】[ \t]*#(\d+)/g)].map(m => Number(m[1])));
|
|
606
|
+
const reviewOf = reviewIds.size === 1 ? this.task([...reviewIds][0]) : undefined;
|
|
607
|
+
const reviewing = reviewOf?.created_by === lead.id && REVIEWABLE.includes(reviewOf.status)
|
|
608
|
+
&& this.chatOf(reviewOf.room_id) === this.chatOf(msg.room_id) ? reviewOf : undefined;
|
|
609
|
+
// Its next message after a chat-side hand-back accepts it. Workspace tasks need its explicit
|
|
610
|
+
// 【验收】#id / 【返工】#id line (verdictsOf): it may be answering something else meanwhile.
|
|
611
|
+
for (const t of this.q.returnedChat.all(msg.room_id, lead.id)) {
|
|
612
|
+
if (t.id === reviewing?.id || this.db.prepare(`SELECT id FROM tasks WHERE parent_task = ? AND source = 'review' AND status IN (${PLACEHOLDERS})`).get(t.id, ...WORKING)) continue;
|
|
613
|
+
try { this.review(t.id, { actor: lead.id, verdict: 'done' }); } catch { /* changed meanwhile */ }
|
|
614
|
+
}
|
|
615
|
+
const explicit = mentions(text, team);
|
|
616
|
+
const called = new Set(explicit);
|
|
617
|
+
const names = team.map(m => m.name);
|
|
618
|
+
for (const m of team) if (nameCalled(text, m.name, names)) called.add(m.id);
|
|
619
|
+
called.delete(lead.id);
|
|
620
|
+
const title = taskTitle(text, team);
|
|
621
|
+
if (!title) return undefined;
|
|
622
|
+
for (const owner of called) {
|
|
623
|
+
if (reviewing !== undefined) {
|
|
624
|
+
this.openFromChat({ roomId: msg.room_id, owner, lead: lead.id, title, messageId: msg.id, source: 'review', reviewOf: reviewing.id });
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
// #597 r12: structure, not wording. Once the owner's newest task here is handed back or
|
|
628
|
+
// accepted, a bare name may be talk about the delivery (praise) or a send-back: the task
|
|
629
|
+
// opens only when her next message here opens with a stance line (settleCall). An explicit
|
|
630
|
+
// @, a first call, or one while her task is in work dispatches at once, as before.
|
|
631
|
+
const key = `${msg.room_id}\0${owner}`;
|
|
632
|
+
this.pendingCalls.delete(key);
|
|
633
|
+
const latest = this.q.latestOf.get(msg.room_id, owner);
|
|
634
|
+
if (DELIVERED.includes(latest?.status) && !explicit.has(owner)) {
|
|
635
|
+
this.pendingCalls.set(key, { roomId: msg.room_id, owner, lead: lead.id, title, messageId: msg.id, at: this.now() });
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
this.openFromChat({ roomId: msg.room_id, owner, lead: lead.id, title, messageId: msg.id, source: 'mention' });
|
|
639
|
+
}
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
// handBack false: a DSH reply in a workspace, which hands back only with [report] (fromMessage).
|
|
643
|
+
if (!handBack) return undefined;
|
|
644
|
+
const callsLead = lead !== undefined && (mentions(text, team).has(lead.id) || nameCalled(text, lead.name, team.map(m => m.name)));
|
|
645
|
+
if (!callsLead) return undefined;
|
|
646
|
+
const open = this.q.openChat.get(msg.room_id, msg.author, lead.id, ...WORKING);
|
|
647
|
+
if (open === undefined || open.source === 'review') return undefined;
|
|
648
|
+
return this.handBack(open.id, { actor: msg.author, messageId: msg.id });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* #597 r12: the owner's next message in a room settles the lead's bare-name call waiting there
|
|
653
|
+
* (fromChat). A first line 【接】【问】【退】【升】 without #id opens the task, which stanceOf then
|
|
654
|
+
* answers with that stance; anything else (thanks, chat), a stance naming #id, or a reply after
|
|
655
|
+
* PENDING_CALL_S drops it.
|
|
656
|
+
*/
|
|
657
|
+
settleCall(msg) {
|
|
658
|
+
const key = `${msg.room_id}\0${msg.author}`;
|
|
659
|
+
const call = this.pendingCalls.get(key);
|
|
660
|
+
if (call === undefined) return;
|
|
661
|
+
this.pendingCalls.delete(key);
|
|
662
|
+
if (this.now() - call.at > PENDING_CALL_S) return;
|
|
663
|
+
const m = STANCE_LINE.exec(withoutLeadingMentions(String(msg.text), this.hub.routable()).split('\n', 1)[0]);
|
|
664
|
+
if (m === null || m[2] !== undefined) return;
|
|
665
|
+
try {
|
|
666
|
+
this.openFromChat({ ...call, source: 'mention' });
|
|
667
|
+
} catch (error) {
|
|
668
|
+
this.hub.logger?.warn?.('collab: pending call failed: %s', error?.message ?? error);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Open a chat-side task unless the owner already has one open from this lead in this room, or one
|
|
674
|
+
* the lead handed to a workspace under it (the lead's later @ just tells the owner about it).
|
|
675
|
+
*/
|
|
676
|
+
openFromChat({ roomId, owner, lead, title, messageId, source, reviewOf }) {
|
|
677
|
+
if (!this.hub.members.has(owner) || this.q.openChat.get(roomId, owner, lead, ...WORKING) !== undefined) return undefined;
|
|
678
|
+
if (this.q.openHanded?.get(owner, lead, ...WORKING, roomId) !== undefined) return undefined;
|
|
679
|
+
// force: the channel already dispatched it; the board records it even if the owner is busy elsewhere.
|
|
680
|
+
const task = this.assign({ roomId, title, owner, actor: lead, messageId, reviewOf, force: true });
|
|
681
|
+
this.q.setSource.run(source, task.id);
|
|
682
|
+
return task;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Bridge task card members (cloud-web public_progress.go task_members) in room roomId,
|
|
687
|
+
* posted by lead as card message messageId: a live top-level member opens its task, one
|
|
688
|
+
* that ended (core.TaskMember: completed/failed/cancelled/interrupted) hands it back bound
|
|
689
|
+
* to the card. Sub-rows (parent_id set) belong to their parent member and are skipped.
|
|
690
|
+
*/
|
|
691
|
+
fromCard(roomId, lead, messageId, members) {
|
|
692
|
+
if (!Array.isArray(members) || !this.hub.members.has(lead)) return;
|
|
693
|
+
const team = this.hub.routableIn(roomId);
|
|
694
|
+
for (const m of members) {
|
|
695
|
+
if (m === null || typeof m !== 'object' || m.parent_id) continue;
|
|
696
|
+
const project = String(m.reporter_project ?? '');
|
|
697
|
+
const name = String(m.name ?? '').toLowerCase();
|
|
698
|
+
const owner = team.find(t => (project && t.id === project) || (name && t.name.toLowerCase() === name));
|
|
699
|
+
if (owner === undefined || owner.id === lead) continue;
|
|
700
|
+
const status = String(m.status ?? '');
|
|
701
|
+
try {
|
|
702
|
+
const mirrored = m.id ? this.db.prepare("SELECT * FROM tasks WHERE room_id = ? AND assign_msg_id = ? AND card_member_id = ? AND source = 'card'").get(roomId, messageId, m.id) : undefined;
|
|
703
|
+
if (mirrored && mirrored.owner !== owner.id && WORKING.includes(mirrored.status)) this.reassignForQuota(mirrored, owner.id);
|
|
704
|
+
if (['queued', 'running', 'waiting'].includes(status)) {
|
|
705
|
+
const title = String(m.title ?? '').trim() || String(m.name ?? '').trim();
|
|
706
|
+
if (title) {
|
|
707
|
+
const opened = this.openFromChat({ roomId, owner: owner.id, lead, title, messageId, source: 'card' });
|
|
708
|
+
if (opened && m.id) this.db.prepare('UPDATE tasks SET card_member_id = ? WHERE id = ?').run(m.id, opened.id);
|
|
709
|
+
}
|
|
710
|
+
// `result` is the public-progress report kind; newer bridge cards
|
|
711
|
+
// normalize it to `completed`, while older cards expose it directly.
|
|
712
|
+
// Keep `done` for cards produced by older task-card implementations.
|
|
713
|
+
} else if (['result', 'completed', 'done', 'failed', 'cancelled', 'interrupted'].includes(status)) {
|
|
714
|
+
const open = this.q.openChat.get(roomId, owner.id, lead, ...WORKING);
|
|
715
|
+
if (open !== undefined && open.source !== 'review') this.handBack(open.id, { actor: owner.id, messageId });
|
|
716
|
+
}
|
|
717
|
+
} catch (error) {
|
|
718
|
+
this.hub.logger?.warn?.('collab: task card sync failed: %s', error?.message ?? error);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* At its check time a workspace task is renewed or parked (watch); a task in any other room
|
|
725
|
+
* gets one reminder @-ing the owner, which also wakes it. A workspace held by a bridge claim is
|
|
726
|
+
* the bridge's to watch (core/team_watch.go): its tasks get neither.
|
|
727
|
+
*/
|
|
315
728
|
tick() {
|
|
316
729
|
if (this.closed || !this.started) return;
|
|
317
|
-
|
|
730
|
+
const now = this.now();
|
|
731
|
+
for (const task of this.q.open.all(...WORKING)) {
|
|
732
|
+
// Only a member that was actually removed closes its work: a merely offline member, or a
|
|
733
|
+
// pause the user asked for, must be able to resume later.
|
|
734
|
+
if (task.owner === HUMAN) continue;
|
|
735
|
+
const removed = this.db.prepare('SELECT removed FROM members WHERE id = ?').get(task.owner)?.removed === 1;
|
|
736
|
+
if (!removed) continue;
|
|
737
|
+
const reason = '负责人已移除,自动收口';
|
|
738
|
+
const row = this.q.autoClose.get(reason, now, now, task.id, ...WORKING);
|
|
739
|
+
if (row === undefined) continue;
|
|
740
|
+
const text = `#${row.id}「${row.title}」${reason}。`;
|
|
741
|
+
if (row.paused_at != null && row.paused_chat != null && row.paused_chat !== this.chatOf(row.room_id)) {
|
|
742
|
+
const card = JSON.stringify({ kind: 'task', task_id: row.id, event: 'return' });
|
|
743
|
+
const message = { authorKind: 'system', author: 'system', name: '任务', text, card };
|
|
744
|
+
this.hub.post(row.room_id, message);
|
|
745
|
+
this.hub.post(row.paused_chat, message);
|
|
746
|
+
} else this.record(row, 'return', text);
|
|
747
|
+
this.releaseIfDone(row.room_id);
|
|
748
|
+
this.store.releaseIdleWorkspaces();
|
|
749
|
+
this.changed(row.id);
|
|
750
|
+
}
|
|
751
|
+
for (const task of this.q.allBusy.all(...WORKING)) {
|
|
752
|
+
if (this.watched(task) && watchDue(task) <= now) this.watch(task, now);
|
|
753
|
+
}
|
|
754
|
+
for (const task of this.q.due.all(now, ...WORKING)) {
|
|
755
|
+
if (this.store.room(task.room_id)?.kind === 'workspace') continue;
|
|
756
|
+
if (this.hub.quota?.cooling(task.owner)) {
|
|
757
|
+
this.q.renew.get(now + CHECK_AFTER_S, task.renewals ?? 0, task.id, ...WORKING);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
318
760
|
const name = this.nameOf(task.owner);
|
|
319
761
|
const { message } = this.hub.post(task.room_id, {
|
|
320
762
|
authorKind: 'system', author: 'system', name: '任务提醒',
|
|
@@ -328,6 +770,85 @@ export class Tasks {
|
|
|
328
770
|
this.arm();
|
|
329
771
|
}
|
|
330
772
|
|
|
773
|
+
/** A workspace task this gateway watches: not in a workspace the bridge claimed. */
|
|
774
|
+
watched(task) {
|
|
775
|
+
return this.store.room(task.room_id)?.kind === 'workspace' && !this.store.bridgeClaimed(task.room_id);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* A workspace task at its check time: progress by its owner since the window opened
|
|
780
|
+
* (watchDue - CHECK_AFTER_S) and fewer than MAX_RENEWALS renewals → another CHECK_AFTER_S;
|
|
781
|
+
* otherwise it is parked through pauseTask, and the pause record @-s the owner and wakes it
|
|
782
|
+
* (the record goes first, while the workspace still names its chat channel).
|
|
783
|
+
*/
|
|
784
|
+
watch(task, now) {
|
|
785
|
+
if (this.hub.quota?.cooling(task.owner)) {
|
|
786
|
+
this.q.renew.get(now + CHECK_AFTER_S, task.renewals ?? 0, task.id, ...WORKING);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const renewals = task.renewals ?? 0;
|
|
790
|
+
// #597 G35: an owner waiting on its [ask] waits on the user, not stuck: no progress check, no
|
|
791
|
+
// reminder, no pause (which would release the workspace the answer comes back to). The watch
|
|
792
|
+
// only moves on, renewals untouched; the answer re-arms it (rewatch).
|
|
793
|
+
if (this.awaitingUser(task)) {
|
|
794
|
+
if (this.q.renew.get(now + CHECK_AFTER_S, renewals, task.id, ...WORKING) !== undefined) this.changed(task.id);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if ((this.maxRenewals <= 0 || renewals < this.maxRenewals) && this.progressed(task, watchDue(task) - CHECK_AFTER_S)) {
|
|
798
|
+
if (this.q.renew.get(now + CHECK_AFTER_S, renewals + 1, task.id, ...WORKING) !== undefined) this.changed(task.id);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const why = this.maxRenewals > 0 && renewals >= this.maxRenewals ? `已续期 ${this.maxRenewals} 次` : `${CHECK_AFTER_S / 60} 分钟内没有新进展`;
|
|
802
|
+
// The owner is woken on this record, often in a fresh session (the pause released the
|
|
803
|
+
// workspace): it says what to answer, as the bridge's watch reminder does (core/team_watch.go).
|
|
804
|
+
const message = this.record(task, 'pause', `@${this.nameOf(task.owner)} #${task.id}「${task.title}」${why},已暂停。`
|
|
805
|
+
+ `做完了就交回:回复第一行写【接】#${task.id},下面写 [report] 和结果;没做完就第一行写【问】#${task.id},说明做到哪一步、卡在哪。${this.briefing(task)}`);
|
|
806
|
+
if (this.pauseTask(task.id)) this.wake(task.owner, message);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* #597 G35: the task's owner is waiting on the user: its latest own text row in the task's
|
|
811
|
+
* workspace is an [ask] (team-exit team_exit_asks, posted after the task was handed over) the user
|
|
812
|
+
* has not answered yet. An older unanswered [ask] the owner moved on from (a later reply, a
|
|
813
|
+
* hand-back, a rework turn) does not hold the watch.
|
|
814
|
+
*/
|
|
815
|
+
awaitingUser(task) {
|
|
816
|
+
if (!this.hub.teamExit?.enabled) return false;
|
|
817
|
+
return this.db.prepare(`SELECT 1 FROM team_exit_asks WHERE workspace_id = ? AND member_id = ? AND answered = 0
|
|
818
|
+
AND source_id > ? AND source_id = (SELECT MAX(id) FROM messages WHERE room_id = ? AND author = ?
|
|
819
|
+
AND deleted = 0 AND card IS NULL) LIMIT 1`)
|
|
820
|
+
.get(task.room_id, task.owner, task.assign_msg_id ?? 0, task.room_id, task.owner) !== undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* #597 G35: the user's answer to owner's [ask] reached workspace roomId (TeamExit): the owner's
|
|
825
|
+
* tasks in work there get a fresh CHECK_AFTER_S watch window from now.
|
|
826
|
+
*/
|
|
827
|
+
rewatch(roomId, owner) {
|
|
828
|
+
const now = this.now();
|
|
829
|
+
for (const task of this.q.allBusy.all(...WORKING)) {
|
|
830
|
+
if (task.room_id !== roomId || task.owner !== owner || !this.watched(task) || this.awaitingUser(task)) continue;
|
|
831
|
+
if (this.q.renew.get(now + CHECK_AFTER_S, task.renewals ?? 0, task.id, ...WORKING) !== undefined) this.changed(task.id);
|
|
832
|
+
}
|
|
833
|
+
this.arm();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Progress = the owner's own rows posted or changed in place since `since`: its progress card
|
|
838
|
+
* (or any card it owns) moving, or text beyond a first stance line (【接】【问】【退】【升】) that is
|
|
839
|
+
* not a status notice. Same rule as contrib/lead/d10-fire chat_active, with the stance line taken
|
|
840
|
+
* off instead of the whole row: a DSH member's one reply opens with 【接】#id and grows in place.
|
|
841
|
+
*/
|
|
842
|
+
progressed(task, since) {
|
|
843
|
+
return this.q.ownerSaid.all(task.room_id, task.owner, since).some(({ text, card }) => {
|
|
844
|
+
if (card != null) return true;
|
|
845
|
+
const lines = withoutLeadingMentions(String(text ?? ''), this.hub.routableIn(task.room_id)).trim().split('\n');
|
|
846
|
+
if (STANCE_LINE.test(lines[0])) lines.shift();
|
|
847
|
+
const body = lines.join('\n').trim();
|
|
848
|
+
return body !== '' && !NOTICE_PREFIXES.some(p => body.startsWith(p));
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
331
852
|
/** Retry every undelivered reminder whose task is still the owner's turn. */
|
|
332
853
|
deliverPending() {
|
|
333
854
|
if (this.closed || !this.started) return;
|
|
@@ -341,22 +862,37 @@ export class Tasks {
|
|
|
341
862
|
const task = this.q.takeRemind.get(taskId, msgId);
|
|
342
863
|
if (task === undefined) return;
|
|
343
864
|
if (message === undefined) return;
|
|
344
|
-
let taken = false;
|
|
345
865
|
this.delivering = true;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
try { if (wake(task.owner, message)) { taken = true; break; } } catch (error) { this.hub.logger.warn('collab: task reminder wake failed: %s', error?.message ?? error); }
|
|
349
|
-
}
|
|
350
|
-
} finally { this.delivering = false; }
|
|
866
|
+
let taken;
|
|
867
|
+
try { taken = this.wake(task.owner, message); } finally { this.delivering = false; }
|
|
351
868
|
if (!taken) this.q.putRemind.run(msgId, taskId);
|
|
352
869
|
}
|
|
353
870
|
|
|
871
|
+
/** Start memberId on message through the first waker that takes it; true if one did. */
|
|
872
|
+
wake(memberId, message) {
|
|
873
|
+
if (this.hub.quota?.cooling(memberId)) return false;
|
|
874
|
+
for (const wake of this.wakers) {
|
|
875
|
+
try { if (wake(memberId, message)) return true; } catch (error) { this.hub.logger.warn('collab: task reminder wake failed: %s', error?.message ?? error); }
|
|
876
|
+
}
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
|
|
354
880
|
/** Schedule tick() for the earliest pending check time. */
|
|
355
881
|
arm() {
|
|
356
882
|
if (this.timer !== undefined) this.clearTimer(this.timer);
|
|
357
883
|
this.timer = undefined;
|
|
358
884
|
if (this.closed) return;
|
|
359
|
-
|
|
885
|
+
// Workspace tasks are due at watchDue (also those whose check time an older reminder spent),
|
|
886
|
+
// except in bridge-claimed workspaces; other tasks at their check time.
|
|
887
|
+
const at = this.q.open.all(...WORKING)
|
|
888
|
+
.map(t => {
|
|
889
|
+
if (t.owner === HUMAN) return null;
|
|
890
|
+
const removed = this.db.prepare('SELECT removed FROM members WHERE id = ?').get(t.owner)?.removed === 1;
|
|
891
|
+
if (removed) return this.now();
|
|
892
|
+
if (t.paused_at != null) return null;
|
|
893
|
+
return this.store.room(t.room_id)?.kind !== 'workspace' ? t.next_check_at : this.watched(t) ? watchDue(t) : null;
|
|
894
|
+
})
|
|
895
|
+
.filter(t => t != null).sort((a, b) => a - b)[0];
|
|
360
896
|
if (at == null) return;
|
|
361
897
|
const ms = Math.min(MAX_TIMER_MS, Math.max(0, (at - this.now()) * 1000));
|
|
362
898
|
this.timer = this.setTimer(() => { this.timer = undefined; this.tick(); }, ms);
|
|
@@ -374,13 +910,17 @@ export class Tasks {
|
|
|
374
910
|
return task;
|
|
375
911
|
}
|
|
376
912
|
|
|
377
|
-
/**
|
|
378
|
-
|
|
913
|
+
/**
|
|
914
|
+
* One system record (task card) in the task room and, for a workspace, its chat channel; none in
|
|
915
|
+
* room skip (the room of the dispatch message, which shows the card itself). Returns the task
|
|
916
|
+
* room's record (undefined when skipped).
|
|
917
|
+
*/
|
|
918
|
+
record(task, event, text, skip) {
|
|
379
919
|
const card = JSON.stringify({ kind: 'task', task_id: task.id, event });
|
|
380
920
|
const m = { authorKind: 'system', author: 'system', name: '任务', text, card };
|
|
381
|
-
const
|
|
921
|
+
const message = task.room_id === skip ? undefined : this.hub.post(task.room_id, m).message;
|
|
382
922
|
const chat = this.chatOf(task.room_id);
|
|
383
|
-
if (chat !== undefined && chat !== task.room_id) this.hub.post(chat, m);
|
|
923
|
+
if (chat !== undefined && chat !== task.room_id && chat !== skip) this.hub.post(chat, m);
|
|
384
924
|
return message;
|
|
385
925
|
}
|
|
386
926
|
|
|
@@ -411,6 +951,53 @@ export class Tasks {
|
|
|
411
951
|
return true;
|
|
412
952
|
}
|
|
413
953
|
|
|
954
|
+
/**
|
|
955
|
+
* Park workspace roomId (the bridge's op release with park, core ParkTeamWorkspace): its tasks
|
|
956
|
+
* in work are marked paused (still the owner's, resumed by any status change) and the workspace
|
|
957
|
+
* is released to the pool like release() (same claim check, 'stale-claim' before anything is
|
|
958
|
+
* paused). Returns whether it was released now.
|
|
959
|
+
*/
|
|
960
|
+
park(roomId, claim = null) {
|
|
961
|
+
if (this.store.room(roomId)?.kind !== 'workspace' || this.store.workspaceOwner(roomId) == null) return false;
|
|
962
|
+
if (claim != null && String(claim) !== this.store.claimId(roomId)) {
|
|
963
|
+
throw new CollabError('stale-claim', `workspace ${roomId} was claimed again since claim ${claim}`);
|
|
964
|
+
}
|
|
965
|
+
this.q.park.run(this.now(), this.store.workspaceOwner(roomId), roomId, ...WORKING);
|
|
966
|
+
const released = this.release(roomId, claim);
|
|
967
|
+
this.arm();
|
|
968
|
+
return released;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Pause one task through the same queue/release path as park; other work stays busy. */
|
|
972
|
+
pauseTask(id) {
|
|
973
|
+
const task = this.task(id);
|
|
974
|
+
if (!task || task.paused_at != null || !WORKING.includes(task.status)
|
|
975
|
+
|| this.store.room(task.room_id)?.kind !== 'workspace') return false;
|
|
976
|
+
this.db.prepare(`UPDATE tasks SET paused_at = ?, paused_chat = ?, next_check_at = NULL
|
|
977
|
+
WHERE id = ?`).run(this.now(), this.store.workspaceOwner(task.room_id), id);
|
|
978
|
+
if (this.countActive(task.room_id) === 0) this.release(task.room_id);
|
|
979
|
+
this.changed(id);
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* A paused task's workspace was released by park and may have been claimed by another chat
|
|
985
|
+
* channel since. Resuming it there would make the workspace serve two chat channels and send
|
|
986
|
+
* its asks/reports (team-exit, by rooms.parent_id) to the later one. So: owned by another chat
|
|
987
|
+
* channel → refused with 'workspace-owned' and nothing changes (the task stays paused, still
|
|
988
|
+
* listed; hand it off again to a free workspace). Free and reclaim → claimed back for the chat
|
|
989
|
+
* channel it was paused under. Tasks not paused, or paused before paused_chat existed, pass.
|
|
990
|
+
*/
|
|
991
|
+
resumeClaim(task, reclaim) {
|
|
992
|
+
if (task.paused_at == null || task.paused_chat == null || !this.tree) return;
|
|
993
|
+
const owner = this.store.workspaceOwner(task.room_id);
|
|
994
|
+
if (owner != null && owner !== task.paused_chat) {
|
|
995
|
+
throw new CollabError('workspace-owned', `task ${task.id} is paused and its workspace now belongs to room ${owner}; hand it off again to a free workspace`);
|
|
996
|
+
}
|
|
997
|
+
// The resumed task is this claim's handoff: handing it back frees the workspace again (handoffDone).
|
|
998
|
+
if (owner == null && reclaim) this.store.claimWorkspace(task.paused_chat, task.room_id, { handoff: task.id });
|
|
999
|
+
}
|
|
1000
|
+
|
|
414
1001
|
changed(id) {
|
|
415
1002
|
const task = this.view(this.task(id));
|
|
416
1003
|
this.hub.emit({ type: 'task', task, chat_id: this.chatOf(task.room_id), active: this.countActive(task.room_id) });
|
|
@@ -420,6 +1007,27 @@ export class Tasks {
|
|
|
420
1007
|
}
|
|
421
1008
|
}
|
|
422
1009
|
|
|
1010
|
+
/**
|
|
1011
|
+
* First non-empty line of a dispatch with the @-mentions taken out (the task title). A stance line
|
|
1012
|
+
* and an [ask]/[report] mark are not content (#597 G37: a lead's [report] opening 【接】#id dispatches too);
|
|
1013
|
+
* the line calling the teammate comes first.
|
|
1014
|
+
*/
|
|
1015
|
+
function taskTitle(text, team) {
|
|
1016
|
+
const clean = l => l.replace(/^[\s,,::、]+/, '').replace(/^\[(?:ask|report)\]/i, '').replace(/^[\s,,::、]+/, '').trim();
|
|
1017
|
+
const lines = String(text).split('\n').map(l => {
|
|
1018
|
+
const segs = split(l, team);
|
|
1019
|
+
return { called: segs.some(([, id]) => id), text: clean(segs.map(([seg, id]) => (id ? '' : seg)).join('')) };
|
|
1020
|
+
}).filter(l => l.text !== '' && !STANCE_LINE.test(l.text));
|
|
1021
|
+
// The line that calls the teammate says what the task is; else the first line of content.
|
|
1022
|
+
const line = (lines.find(l => l.called) ?? lines[0])?.text ?? '';
|
|
1023
|
+
return line.slice(0, MAX_TEXT);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** When a workspace task's watch is due: its check time, or CHECK_AFTER_S after its last change. */
|
|
1027
|
+
function watchDue(task) {
|
|
1028
|
+
return task.next_check_at ?? task.updated_at + CHECK_AFTER_S;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
423
1031
|
function clip(note) {
|
|
424
1032
|
const s = String(note ?? '').trim();
|
|
425
1033
|
return s ? s.slice(0, MAX_TEXT) : null;
|