crewly 1.20.179 → 1.20.180
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/config/roles/team-leader/prompt.md +2 -2
- package/config/roles/team-leader/tl-addon.md +2 -2
- package/config/skills/agent/core/ask-owner/SKILL.md +79 -0
- package/config/skills/agent/core/ask-owner/execute.sh +66 -0
- package/config/skills/agent/core/ask-owner/execute.test.sh +62 -0
- package/config/skills/agent/core/project-tickets/SKILL.md +21 -9
- package/config/skills/agent/core/project-tickets/execute.sh +28 -9
- package/config/skills/agent/core/project-tickets/execute.test.sh +12 -4
- package/config/skills/agent/core/reply/SKILL.md +22 -5
- package/config/skills/agent/core/reply/execute.sh +12 -5
- package/config/skills/agent/core/reply/execute.test.sh +8 -0
- package/config/skills/agent/core/schedule-followup/SKILL.md +8 -0
- package/config/skills/agent/core/schedule-followup/execute.sh +7 -0
- package/config/slack-app-manifest.json +5 -1
- package/config/slack-master-app-manifest.json +7 -2
- package/dist/backend/backend/src/constants.d.ts +76 -5
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +83 -5
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +3 -0
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +54 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts +67 -0
- package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/decisions/decisions.controller.js +215 -0
- package/dist/backend/backend/src/controllers/decisions/decisions.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts +8 -5
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js +51 -12
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +4 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +88 -2
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +6 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/boot/boot-announce.service.d.ts +1 -1
- package/dist/backend/backend/src/services/boot/boot-announce.service.js +13 -13
- package/dist/backend/backend/src/services/boot/boot-announce.service.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.js +6 -6
- package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.d.ts +3 -3
- package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.js +14 -13
- package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision-card.d.ts +147 -0
- package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-card.js +311 -0
- package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-contract.d.ts +83 -0
- package/dist/backend/backend/src/services/decisions/decision-contract.d.ts.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-contract.js +226 -0
- package/dist/backend/backend/src/services/decisions/decision-contract.js.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-routing.d.ts +34 -0
- package/dist/backend/backend/src/services/decisions/decision-routing.d.ts.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-routing.js +64 -0
- package/dist/backend/backend/src/services/decisions/decision-routing.js.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-store.d.ts +80 -0
- package/dist/backend/backend/src/services/decisions/decision-store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-store.js +151 -0
- package/dist/backend/backend/src/services/decisions/decision-store.js.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +281 -0
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision.service.js +639 -0
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts +70 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.js +172 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/decisions/ticket-thread-store.d.ts +97 -0
- package/dist/backend/backend/src/services/decisions/ticket-thread-store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/decisions/ticket-thread-store.js +137 -0
- package/dist/backend/backend/src/services/decisions/ticket-thread-store.js.map +1 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.d.ts +165 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.js +239 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.js.map +1 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts +134 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js +217 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts +1 -30
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js +1 -22
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts +2 -15
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js +8 -22
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts +11 -12
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js +27 -52
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts +6 -0
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +3 -1
- package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +12 -12
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts +16 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.js +56 -2
- package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/backend/backend/src/services/system/auto-update.service.js +2 -2
- package/dist/backend/backend/src/services/system/auto-update.service.js.map +1 -1
- package/dist/backend/backend/src/services/system/auto-update.utils.js +2 -2
- package/dist/backend/backend/src/services/system/auto-update.utils.js.map +1 -1
- package/dist/backend/backend/src/types/decision.types.d.ts +139 -0
- package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/decision.types.js +13 -0
- package/dist/backend/backend/src/types/decision.types.js.map +1 -0
- package/dist/backend/backend/src/types/slack.types.d.ts +30 -0
- package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/slack.types.js.map +1 -1
- package/dist/backend/backend/src/types/v2/trigger.types.d.ts +17 -0
- package/dist/backend/backend/src/types/v2/trigger.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/trigger.types.js +17 -0
- package/dist/backend/backend/src/types/v2/trigger.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +76 -5
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +83 -5
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +12 -12
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts +16 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.js +56 -2
- package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/cli/backend/src/services/system/auto-update.utils.js +2 -2
- package/dist/cli/backend/src/services/system/auto-update.utils.js.map +1 -1
- package/dist/cli/backend/src/types/slack.types.d.ts +30 -0
- package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/slack.types.js.map +1 -1
- package/dist/cli/backend/src/types/v2/trigger.types.d.ts +17 -0
- package/dist/cli/backend/src/types/v2/trigger.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/trigger.types.js +17 -0
- package/dist/cli/backend/src/types/v2/trigger.types.js.map +1 -1
- package/frontend/dist/assets/index-04d192f8.css +42 -0
- package/frontend/dist/assets/{index-69af46c3.js → index-4271e95e.js} +259 -259
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-cdc52122.css +0 -42
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision cards (specs/2026-10-01-decision-cards.md).
|
|
3
|
+
*
|
|
4
|
+
* The responsible agent asks the owner ONE structured question; its own
|
|
5
|
+
* Slack bot posts a Block Kit card in the team channel (the ticket's thread,
|
|
6
|
+
* or a new thread), and the owner answers with a button, a reaction, a
|
|
7
|
+
* thread reply or the dashboard. The answer updates the card in place, goes
|
|
8
|
+
* into the ticket log, is delivered to the asking agent and clears the
|
|
9
|
+
* owner-message watchdog for that thread. At the deadline the default is
|
|
10
|
+
* applied — except for sensitive asks, which are re-asked once and parked.
|
|
11
|
+
*
|
|
12
|
+
* Every collaborator is injected ({@link DecisionServiceDeps}); the wiring
|
|
13
|
+
* with the real Slack / tickets / agents lives in `decision.wiring.ts`.
|
|
14
|
+
*
|
|
15
|
+
* @module services/decisions/decision.service
|
|
16
|
+
*/
|
|
17
|
+
import { DECISION_CONSTANTS, ORCHESTRATOR_SESSION_NAME } from '../../constants.js';
|
|
18
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
19
|
+
import { DecisionContractError, validateAskOwner } from './decision-contract.js';
|
|
20
|
+
import { cardFallbackText, choiceFromReaction, choiceFromText, deadlineDefaultLine, defaultLabel, formatWhen, optionLabel, parseButtonValue, renderOpenCard, renderSettledCard, settledLine, ticketThreadRootText, } from './decision-card.js';
|
|
21
|
+
import { PENDING_DECISION_STATUSES } from './decision-store.js';
|
|
22
|
+
/** Thrown for requests the caller must fix (HTTP 4xx). */
|
|
23
|
+
export class DecisionError extends Error {
|
|
24
|
+
status;
|
|
25
|
+
constructor(status, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.name = 'DecisionError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Next local `hour`:00 strictly tomorrow.
|
|
33
|
+
*
|
|
34
|
+
* @param now - Clock
|
|
35
|
+
* @param hour - Local hour
|
|
36
|
+
* @returns Date
|
|
37
|
+
*/
|
|
38
|
+
function tomorrowAt(now, hour) {
|
|
39
|
+
const d = new Date(now.getTime());
|
|
40
|
+
d.setDate(d.getDate() + 1);
|
|
41
|
+
d.setHours(hour, 0, 0, 0);
|
|
42
|
+
return d;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decision cards service.
|
|
46
|
+
*/
|
|
47
|
+
export class DecisionService {
|
|
48
|
+
static instance = null;
|
|
49
|
+
deps;
|
|
50
|
+
logger;
|
|
51
|
+
now;
|
|
52
|
+
timer = null;
|
|
53
|
+
ticking = false;
|
|
54
|
+
/**
|
|
55
|
+
* @param deps - Collaborators
|
|
56
|
+
*/
|
|
57
|
+
constructor(deps) {
|
|
58
|
+
this.deps = deps;
|
|
59
|
+
this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('DecisionCards');
|
|
60
|
+
this.now = deps.now ?? (() => new Date());
|
|
61
|
+
}
|
|
62
|
+
/** @returns The process-wide instance, or null before wiring */
|
|
63
|
+
static getInstance() {
|
|
64
|
+
return DecisionService.instance;
|
|
65
|
+
}
|
|
66
|
+
/** @param service - Instance to install (null clears) */
|
|
67
|
+
static setInstance(service) {
|
|
68
|
+
DecisionService.instance = service;
|
|
69
|
+
}
|
|
70
|
+
/** Start the deadline / reminder tick. */
|
|
71
|
+
start() {
|
|
72
|
+
if (this.timer)
|
|
73
|
+
return;
|
|
74
|
+
this.timer = setInterval(() => void this.tick(), DECISION_CONSTANTS.TICK_MS);
|
|
75
|
+
this.timer.unref?.();
|
|
76
|
+
}
|
|
77
|
+
/** Stop the tick. */
|
|
78
|
+
stop() {
|
|
79
|
+
if (this.timer)
|
|
80
|
+
clearInterval(this.timer);
|
|
81
|
+
this.timer = null;
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Asking
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* Ask the owner. Validates the contract, stores the decision and posts the
|
|
88
|
+
* card as the responsible agent's own bot.
|
|
89
|
+
*
|
|
90
|
+
* @param callerSession - Agent calling ask-owner (undefined = owner / dashboard)
|
|
91
|
+
* @param input - The ask
|
|
92
|
+
* @returns The stored decision (with `card`, or `postError` when Slack refused; retried on the tick)
|
|
93
|
+
* @throws DecisionError(400) for a contract violation, other 4xx from ticket resolution
|
|
94
|
+
*/
|
|
95
|
+
async ask(callerSession, input) {
|
|
96
|
+
let ask;
|
|
97
|
+
try {
|
|
98
|
+
ask = validateAskOwner(input, this.now());
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (err instanceof DecisionContractError)
|
|
102
|
+
throw new DecisionError(400, err.message);
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
let asker = callerSession;
|
|
106
|
+
let ticket;
|
|
107
|
+
let teamId;
|
|
108
|
+
if (ask.ticketId && ask.project) {
|
|
109
|
+
const ctx = await this.deps.resolveTicket(ask.project, ask.ticketId, callerSession);
|
|
110
|
+
asker = ctx.asker;
|
|
111
|
+
teamId = ctx.teamId;
|
|
112
|
+
ticket = { projectId: ctx.projectId, projectPath: ctx.projectPath, projectName: ctx.projectName, id: ctx.id, title: ctx.title };
|
|
113
|
+
}
|
|
114
|
+
if (!asker)
|
|
115
|
+
throw new DecisionError(400, 'Who is asking? Run ask-owner from an agent session, or name a --ticket.');
|
|
116
|
+
if (!teamId)
|
|
117
|
+
teamId = await this.deps.teamOf(asker).catch(() => undefined);
|
|
118
|
+
const workItemId = callerSession ? await this.deps.currentWorkItemId?.(callerSession).catch(() => undefined) : undefined;
|
|
119
|
+
const decision = await this.deps.store.create({
|
|
120
|
+
question: ask.question,
|
|
121
|
+
options: ask.options,
|
|
122
|
+
defaultKey: ask.defaultKey,
|
|
123
|
+
deadline: ask.deadline.toISOString(),
|
|
124
|
+
...(ask.sensitive ? { sensitive: ask.sensitive } : {}),
|
|
125
|
+
requestedBy: callerSession ?? 'owner',
|
|
126
|
+
asker,
|
|
127
|
+
...(ticket ? { ticket } : {}),
|
|
128
|
+
...(teamId ? { teamId } : {}),
|
|
129
|
+
...(workItemId ? { workItemId } : {}),
|
|
130
|
+
status: 'open',
|
|
131
|
+
});
|
|
132
|
+
if (ticket && ask.ticketId && ask.project) {
|
|
133
|
+
await this.deps
|
|
134
|
+
.markTicketAsked({ ...ticket, asker, teamId }, ask.question, decision.id)
|
|
135
|
+
.catch((err) => this.logger.warn('Could not mark the ticket as waiting on the owner', { decisionId: decision.id, error: errText(err) }));
|
|
136
|
+
}
|
|
137
|
+
this.logger.info('Owner decision asked', { decisionId: decision.id, asker, requestedBy: decision.requestedBy, ticket: ticket?.id, sensitive: decision.sensitive });
|
|
138
|
+
return this.postCard(decision);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Post (or re-try posting) the card of an open decision.
|
|
142
|
+
*
|
|
143
|
+
* @param decision - Decision without a card
|
|
144
|
+
* @returns The decision with `card` or `postError`
|
|
145
|
+
*/
|
|
146
|
+
async postCard(decision) {
|
|
147
|
+
const slack = this.deps.slack();
|
|
148
|
+
if (!slack || !slack.isConnected()) {
|
|
149
|
+
return (await this.deps.store.update(decision.id, () => ({ postError: 'Slack is not connected' }))) ?? decision;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const identity = await this.deps.identityOf(decision.asker);
|
|
153
|
+
const place = await this.placeFor(decision, identity, slack);
|
|
154
|
+
const blocks = renderOpenCard(decision, this.deps.instanceId(), this.now());
|
|
155
|
+
const { ts, ownBot } = await this.send(slack, identity, {
|
|
156
|
+
channelId: place.slackChannelId,
|
|
157
|
+
text: cardFallbackText(decision),
|
|
158
|
+
blocks,
|
|
159
|
+
...(place.threadTs ? { threadTs: place.threadTs } : {}),
|
|
160
|
+
});
|
|
161
|
+
const updated = await this.deps.store.update(decision.id, () => ({
|
|
162
|
+
card: {
|
|
163
|
+
slackChannelId: place.slackChannelId,
|
|
164
|
+
messageTs: ts,
|
|
165
|
+
...(place.threadTs ? { threadTs: place.threadTs } : {}),
|
|
166
|
+
postedBy: ownBot ? decision.asker : 'crewly',
|
|
167
|
+
ownBot,
|
|
168
|
+
},
|
|
169
|
+
...(place.teamId && !decision.teamId ? { teamId: place.teamId } : {}),
|
|
170
|
+
postError: undefined,
|
|
171
|
+
}));
|
|
172
|
+
this.logger.info('Decision card posted', { decisionId: decision.id, channel: place.slackChannelId, threaded: !!place.threadTs, ownBot });
|
|
173
|
+
return updated ?? decision;
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
const msg = err instanceof DecisionError ? err.message : errText(err);
|
|
177
|
+
this.logger.warn('Decision card not posted (retried on the next tick)', { decisionId: decision.id, error: msg });
|
|
178
|
+
return (await this.deps.store.update(decision.id, () => ({ postError: msg }))) ?? decision;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Where the card goes: the ticket's thread (created on first use), else the
|
|
183
|
+
* asker's current work destination, else a new thread in its team channel.
|
|
184
|
+
*/
|
|
185
|
+
async placeFor(decision, identity, slack) {
|
|
186
|
+
if (decision.ticket) {
|
|
187
|
+
const t = decision.ticket;
|
|
188
|
+
const existing = await this.deps.threads.get(t.projectPath, t.id);
|
|
189
|
+
if (existing)
|
|
190
|
+
return { slackChannelId: existing.slackChannelId, threadTs: existing.threadTs, teamId: existing.teamId };
|
|
191
|
+
const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
|
|
192
|
+
if (!channel)
|
|
193
|
+
throw new DecisionError(409, `No Slack team channel for ${t.id}'s team — link the team to Slack first`);
|
|
194
|
+
const { ts } = await this.send(slack, identity, { channelId: channel, text: ticketThreadRootText(t) });
|
|
195
|
+
const thread = await this.deps.threads.set(t.projectPath, t.id, { slackChannelId: channel, threadTs: ts, ...(decision.teamId ? { teamId: decision.teamId } : {}) });
|
|
196
|
+
return { slackChannelId: thread.slackChannelId, threadTs: thread.threadTs, teamId: thread.teamId };
|
|
197
|
+
}
|
|
198
|
+
const work = await this.deps.workDestination?.(decision.asker).catch(() => null);
|
|
199
|
+
if (work?.slackChannelId)
|
|
200
|
+
return work;
|
|
201
|
+
const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
|
|
202
|
+
if (!channel)
|
|
203
|
+
throw new DecisionError(409, `No Slack place to ask in: ${decision.asker} has no team channel and no Slack conversation in hand`);
|
|
204
|
+
return { slackChannelId: channel, ...(decision.teamId ? { teamId: decision.teamId } : {}) };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Send as the agent's own bot; when Slack refuses that bot (not in the
|
|
208
|
+
* channel, revoked), fall back to the shared bot with the agent's name.
|
|
209
|
+
*/
|
|
210
|
+
async send(slack, identity, message) {
|
|
211
|
+
const base = { ...message, skipChatV2Mirror: true };
|
|
212
|
+
if (identity.botToken) {
|
|
213
|
+
try {
|
|
214
|
+
return { ts: await slack.sendMessage({ ...base, botToken: identity.botToken }), ownBot: true };
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
this.logger.warn("Agent's own bot could not post the decision card — using the shared bot", { channel: message.channelId, error: errText(err) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const { botToken: _drop, ...shared } = identity;
|
|
221
|
+
return { ts: await slack.sendMessage({ ...base, ...shared }), ownBot: false };
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Answers
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
/**
|
|
227
|
+
* A Slack `block_actions` payload (Cloud relay, Socket Mode or the HTTP
|
|
228
|
+
* endpoint). Ignores anything that is not one of this instance's cards.
|
|
229
|
+
*
|
|
230
|
+
* @param payload - Slack payload
|
|
231
|
+
* @returns What happened
|
|
232
|
+
*/
|
|
233
|
+
async handleInteraction(payload) {
|
|
234
|
+
const action = payload?.actions?.[0];
|
|
235
|
+
if (!action?.action_id?.startsWith(DECISION_CONSTANTS.ACTION_PREFIX))
|
|
236
|
+
return { handled: false, reason: 'not a decision action' };
|
|
237
|
+
const value = parseButtonValue(action.value);
|
|
238
|
+
if (!value)
|
|
239
|
+
return { handled: false, reason: 'unreadable button value' };
|
|
240
|
+
const self = this.deps.instanceId();
|
|
241
|
+
if (value.i && self && value.i !== self)
|
|
242
|
+
return { handled: false, reason: `card belongs to instance ${value.i}` };
|
|
243
|
+
const decision = await this.deps.store.get(value.d);
|
|
244
|
+
if (!decision)
|
|
245
|
+
return { handled: false, reason: `unknown decision ${value.d}` };
|
|
246
|
+
const channel = payload.container?.channel_id ?? payload.channel?.id;
|
|
247
|
+
const ts = payload.container?.message_ts ?? payload.message?.ts;
|
|
248
|
+
if (!decision.card || decision.card.slackChannelId !== channel || decision.card.messageTs !== ts) {
|
|
249
|
+
return { handled: false, reason: 'click is not on the stored card' };
|
|
250
|
+
}
|
|
251
|
+
const user = payload.user?.id ?? '';
|
|
252
|
+
if (!user || !this.deps.isOwner(user)) {
|
|
253
|
+
this.logger.info('Decision click by someone other than the owner — ignored', { decisionId: decision.id, user });
|
|
254
|
+
return { handled: false, reason: 'not the owner', decision };
|
|
255
|
+
}
|
|
256
|
+
if (decision.status !== 'open')
|
|
257
|
+
return { handled: false, reason: `already ${decision.status}`, decision };
|
|
258
|
+
const choice = value.o === 'remind' ? { kind: 'remind' } : { kind: 'option', key: value.o };
|
|
259
|
+
return this.apply(decision, choice, 'button', user);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* A reaction on a card: ✅ default/first, ❌ the "no" option, ⏰ remind.
|
|
263
|
+
*
|
|
264
|
+
* @param event - `reaction_added` event
|
|
265
|
+
* @returns What happened
|
|
266
|
+
*/
|
|
267
|
+
async handleReaction(event) {
|
|
268
|
+
const channel = event?.item?.channel;
|
|
269
|
+
const ts = event?.item?.ts;
|
|
270
|
+
if (!channel || !ts || !event.reaction)
|
|
271
|
+
return { handled: false, reason: 'not a message reaction' };
|
|
272
|
+
const decision = await this.deps.store.findByCard(channel, ts);
|
|
273
|
+
if (!decision)
|
|
274
|
+
return { handled: false, reason: 'not a decision card' };
|
|
275
|
+
if (decision.status !== 'open')
|
|
276
|
+
return { handled: false, reason: `already ${decision.status}`, decision };
|
|
277
|
+
if (!event.user || !this.deps.isOwner(event.user))
|
|
278
|
+
return { handled: false, reason: 'not the owner', decision };
|
|
279
|
+
const choice = choiceFromReaction(decision, event.reaction);
|
|
280
|
+
if (!choice)
|
|
281
|
+
return { handled: false, reason: `reaction :${event.reaction}: means nothing here`, decision };
|
|
282
|
+
return this.apply(decision, choice, 'reaction', event.user);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* An owner reply in a card's thread. The newest open card in that thread
|
|
286
|
+
* takes it; words that match no option are passed to the asker verbatim.
|
|
287
|
+
*
|
|
288
|
+
* @param message - Inbound Slack message
|
|
289
|
+
* @returns What happened
|
|
290
|
+
*/
|
|
291
|
+
async handleThreadReply(message) {
|
|
292
|
+
if (!message.threadTs || message.threadTs === message.ts)
|
|
293
|
+
return { handled: false, reason: 'not a thread reply' };
|
|
294
|
+
if (message.authorAgentSession)
|
|
295
|
+
return { handled: false, reason: 'written by an agent' };
|
|
296
|
+
if (!message.userId || !this.deps.isOwner(message.userId))
|
|
297
|
+
return { handled: false, reason: 'not the owner' };
|
|
298
|
+
const candidates = await this.deps.store.list((d) => d.status === 'open' &&
|
|
299
|
+
!!d.card &&
|
|
300
|
+
d.card.slackChannelId === message.channelId &&
|
|
301
|
+
(d.card.threadTs === message.threadTs || d.card.messageTs === message.threadTs));
|
|
302
|
+
const decision = candidates[0];
|
|
303
|
+
if (!decision)
|
|
304
|
+
return { handled: false, reason: 'no open card in this thread' };
|
|
305
|
+
const choice = choiceFromText(decision, message.text ?? '');
|
|
306
|
+
if (!choice)
|
|
307
|
+
return { handled: false, reason: 'empty reply', decision };
|
|
308
|
+
return this.apply(decision, choice, 'reply', message.userId);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The owner answered from the dashboard.
|
|
312
|
+
*
|
|
313
|
+
* @param id - Decision id
|
|
314
|
+
* @param optionKey - Option key (or label / number)
|
|
315
|
+
* @returns The resolved decision
|
|
316
|
+
* @throws DecisionError(404/409/400)
|
|
317
|
+
*/
|
|
318
|
+
async chooseFromDashboard(id, optionKey) {
|
|
319
|
+
const decision = await this.requirePending(id);
|
|
320
|
+
const opt = decision.options.find((o) => o.key === optionKey) ?? decision.options.find((o) => o.label.toLowerCase() === String(optionKey).toLowerCase());
|
|
321
|
+
if (!opt)
|
|
322
|
+
throw new DecisionError(400, `"${optionKey}" is not an option of ${id} (${decision.options.map((o) => o.key).join(', ')})`);
|
|
323
|
+
const out = await this.apply(decision, { kind: 'option', key: opt.key }, 'dashboard', this.deps.ownerUserId?.() ?? undefined);
|
|
324
|
+
return out.decision ?? decision;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* "Remind me tomorrow" from the dashboard.
|
|
328
|
+
*
|
|
329
|
+
* @param id - Decision id
|
|
330
|
+
* @returns The snoozed decision
|
|
331
|
+
*/
|
|
332
|
+
async remindFromDashboard(id) {
|
|
333
|
+
const decision = await this.requirePending(id);
|
|
334
|
+
const out = await this.apply(decision, { kind: 'remind' }, 'dashboard', this.deps.ownerUserId?.() ?? undefined);
|
|
335
|
+
return out.decision ?? decision;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Withdraw open decisions (the asker no longer needs an answer, or the
|
|
339
|
+
* ticket's mark was cleared).
|
|
340
|
+
*
|
|
341
|
+
* @param filter - Which decisions
|
|
342
|
+
* @param note - Why (ticket log)
|
|
343
|
+
* @returns How many were withdrawn
|
|
344
|
+
*/
|
|
345
|
+
async cancelWhere(filter, note) {
|
|
346
|
+
const open = await this.deps.store.list((d) => PENDING_DECISION_STATUSES.has(d.status) && filter(d));
|
|
347
|
+
for (const d of open) {
|
|
348
|
+
const done = await this.deps.store.update(d.id, (cur) => (PENDING_DECISION_STATUSES.has(cur.status) ? { status: 'cancelled', resolvedAt: this.now().toISOString() } : null));
|
|
349
|
+
if (!done)
|
|
350
|
+
continue;
|
|
351
|
+
await this.refreshCard(done);
|
|
352
|
+
this.logger.info('Owner decision withdrawn', { decisionId: d.id, note });
|
|
353
|
+
}
|
|
354
|
+
return open.length;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* List decisions.
|
|
358
|
+
*
|
|
359
|
+
* @param which - `open` (open + parked) or `all`
|
|
360
|
+
* @returns Decisions, newest first
|
|
361
|
+
*/
|
|
362
|
+
async list(which = 'open') {
|
|
363
|
+
const all = await this.deps.store.list(which === 'open' ? (d) => PENDING_DECISION_STATUSES.has(d.status) : undefined);
|
|
364
|
+
return all.slice(0, DECISION_CONSTANTS.MAX_LISTED);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* One decision.
|
|
368
|
+
*
|
|
369
|
+
* @param id - Decision id
|
|
370
|
+
* @returns Decision or null
|
|
371
|
+
*/
|
|
372
|
+
get(id) {
|
|
373
|
+
return this.deps.store.get(id);
|
|
374
|
+
}
|
|
375
|
+
async requirePending(id) {
|
|
376
|
+
const decision = await this.deps.store.get(id);
|
|
377
|
+
if (!decision)
|
|
378
|
+
throw new DecisionError(404, `Decision ${id} not found`);
|
|
379
|
+
if (!PENDING_DECISION_STATUSES.has(decision.status))
|
|
380
|
+
throw new DecisionError(409, `Decision ${id} is already ${decision.status}`);
|
|
381
|
+
return decision;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Apply a choice: resolve (or snooze), update the card, log the ticket,
|
|
385
|
+
* tell the asker, close the watchdog entry.
|
|
386
|
+
*/
|
|
387
|
+
async apply(decision, choice, via, user) {
|
|
388
|
+
const now = this.now();
|
|
389
|
+
if (choice.kind === 'remind') {
|
|
390
|
+
const remindAt = tomorrowAt(now, DECISION_CONSTANTS.REMIND_HOUR_LOCAL);
|
|
391
|
+
const minDeadline = remindAt.getTime() + DECISION_CONSTANTS.REMIND_GRACE_MS;
|
|
392
|
+
const updated = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
|
|
393
|
+
? {
|
|
394
|
+
status: 'open',
|
|
395
|
+
remindAt: remindAt.toISOString(),
|
|
396
|
+
deadline: new Date(Math.max(Date.parse(cur.deadline), minDeadline)).toISOString(),
|
|
397
|
+
// A parked sensitive ask that is snoozed gets its re-ask cycle back.
|
|
398
|
+
reaskedAt: cur.status === 'parked' ? undefined : cur.reaskedAt,
|
|
399
|
+
}
|
|
400
|
+
: null);
|
|
401
|
+
if (!updated)
|
|
402
|
+
return { handled: false, reason: 'already settled', decision };
|
|
403
|
+
await this.refreshCard(updated);
|
|
404
|
+
if (updated.ticket)
|
|
405
|
+
await this.logTicket(updated, `owner decision ${updated.id}: remind tomorrow (${via})`, false);
|
|
406
|
+
this.logger.info('Owner decision snoozed to tomorrow', { decisionId: decision.id, via });
|
|
407
|
+
return { handled: true, reason: 'snoozed', decision: updated };
|
|
408
|
+
}
|
|
409
|
+
const patch = choice.kind === 'option'
|
|
410
|
+
? { status: 'resolved', chosenKey: choice.key, answerText: undefined }
|
|
411
|
+
: { status: 'resolved', chosenKey: undefined, answerText: choice.text.slice(0, 2000) };
|
|
412
|
+
const resolved = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
|
|
413
|
+
? { ...patch, answeredVia: via, ...(user ? { answeredBy: user } : {}), resolvedAt: now.toISOString(), remindAt: undefined }
|
|
414
|
+
: null);
|
|
415
|
+
if (!resolved)
|
|
416
|
+
return { handled: false, reason: 'already settled', decision };
|
|
417
|
+
await this.refreshCard(resolved);
|
|
418
|
+
const answer = resolved.chosenKey ? optionLabel(resolved, resolved.chosenKey) : `“${resolved.answerText}”`;
|
|
419
|
+
if (resolved.ticket)
|
|
420
|
+
await this.logTicket(resolved, `owner decision ${resolved.id}: ${answer} (${via})`, true);
|
|
421
|
+
await this.tellAsker(resolved, this.answerNote(resolved));
|
|
422
|
+
this.closeWatchdog(resolved);
|
|
423
|
+
this.logger.info('Owner decision resolved', { decisionId: resolved.id, via, chosen: resolved.chosenKey ?? 'text' });
|
|
424
|
+
return { handled: true, reason: 'resolved', decision: resolved };
|
|
425
|
+
}
|
|
426
|
+
/** The note the asker receives when the owner answered. */
|
|
427
|
+
answerNote(d) {
|
|
428
|
+
const where = this.whereLine(d);
|
|
429
|
+
const about = `for: "${d.question}"${d.ticket ? ` (ticket ${d.ticket.id})` : ''}`;
|
|
430
|
+
if (d.chosenKey) {
|
|
431
|
+
return `[DECISION ${d.id}] The owner chose "${optionLabel(d, d.chosenKey)}" ${about}. Act on it now.${where}`;
|
|
432
|
+
}
|
|
433
|
+
return `[DECISION ${d.id}] The owner answered in words ${about}: "${d.answerText ?? ''}". Read it as their decision and act on it; if it is genuinely unclear, ask once more with ask-owner.${where}`;
|
|
434
|
+
}
|
|
435
|
+
/** Where the asker's follow-up belongs. */
|
|
436
|
+
whereLine(d) {
|
|
437
|
+
if (!d.card)
|
|
438
|
+
return '';
|
|
439
|
+
const thread = d.card.threadTs ?? d.card.messageTs;
|
|
440
|
+
return ` Post any update in the card's thread (--thread ${d.card.slackChannelId}:${thread}).`;
|
|
441
|
+
}
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Deadlines and reminders
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
/**
|
|
446
|
+
* One pass: retry unposted cards, post due reminders, apply defaults at
|
|
447
|
+
* the deadline, re-ask / park sensitive asks, prune old decisions.
|
|
448
|
+
*
|
|
449
|
+
* @returns Ids acted on
|
|
450
|
+
*/
|
|
451
|
+
async tick() {
|
|
452
|
+
if (this.ticking)
|
|
453
|
+
return [];
|
|
454
|
+
this.ticking = true;
|
|
455
|
+
const acted = [];
|
|
456
|
+
try {
|
|
457
|
+
const now = this.now();
|
|
458
|
+
for (const d of await this.deps.store.list((x) => x.status === 'open')) {
|
|
459
|
+
try {
|
|
460
|
+
if (!d.card) {
|
|
461
|
+
// A card Slack refused is retried every few minutes, not every tick.
|
|
462
|
+
if (d.postError && now.getTime() - Date.parse(d.updatedAt) < DECISION_CONSTANTS.POST_RETRY_MS)
|
|
463
|
+
continue;
|
|
464
|
+
if ((await this.postCard(d)).card)
|
|
465
|
+
acted.push(d.id);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (d.remindAt && Date.parse(d.remindAt) <= now.getTime()) {
|
|
469
|
+
await this.remindNow(d);
|
|
470
|
+
acted.push(d.id);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (Date.parse(d.deadline) > now.getTime())
|
|
474
|
+
continue;
|
|
475
|
+
if (d.sensitive) {
|
|
476
|
+
if (await this.sensitiveStep(d, now))
|
|
477
|
+
acted.push(d.id);
|
|
478
|
+
}
|
|
479
|
+
else if (await this.applyDefault(d, now)) {
|
|
480
|
+
acted.push(d.id);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
this.logger.warn('Decision tick step failed', { decisionId: d.id, error: errText(err) });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
await this.deps.store.prune().catch(() => 0);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
this.ticking = false;
|
|
491
|
+
}
|
|
492
|
+
return acted;
|
|
493
|
+
}
|
|
494
|
+
/** Post the "Remind me tomorrow" reminder in the card's thread. */
|
|
495
|
+
async remindNow(d) {
|
|
496
|
+
const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && cur.remindAt ? { remindAt: undefined } : null));
|
|
497
|
+
if (!updated)
|
|
498
|
+
return;
|
|
499
|
+
const owner = this.deps.ownerUserId?.();
|
|
500
|
+
await this.postInThread(updated, `${owner ? `<@${owner}> ` : ''}Reminder: ${updated.question} (answer on the card above)`);
|
|
501
|
+
await this.refreshCard(updated);
|
|
502
|
+
}
|
|
503
|
+
/** Non-sensitive deadline: apply the default (or say it keeps waiting). */
|
|
504
|
+
async applyDefault(d, now) {
|
|
505
|
+
const line = deadlineDefaultLine(d, now);
|
|
506
|
+
if (d.defaultKey === DECISION_CONSTANTS.WAIT_DEFAULT) {
|
|
507
|
+
if (d.deadlineNoticeAt)
|
|
508
|
+
return false;
|
|
509
|
+
const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.deadlineNoticeAt ? { deadlineNoticeAt: now.toISOString() } : null));
|
|
510
|
+
if (!updated)
|
|
511
|
+
return false;
|
|
512
|
+
await this.postInThread(updated, line);
|
|
513
|
+
if (updated.ticket)
|
|
514
|
+
await this.logTicket(updated, `owner decision ${updated.id}: no answer by the deadline — still waiting`, false);
|
|
515
|
+
await this.tellAsker(updated, `[DECISION ${updated.id}] No answer by the deadline for: "${updated.question}". The default is to wait — keep this work parked until the owner answers.${this.whereLine(updated)}`);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
const resolved = await this.deps.store.update(d.id, (cur) => cur.status === 'open' ? { status: 'defaulted', chosenKey: cur.defaultKey, answeredVia: 'deadline', resolvedAt: now.toISOString() } : null);
|
|
519
|
+
if (!resolved)
|
|
520
|
+
return false;
|
|
521
|
+
await this.refreshCard(resolved);
|
|
522
|
+
await this.postInThread(resolved, line);
|
|
523
|
+
if (resolved.ticket)
|
|
524
|
+
await this.logTicket(resolved, `owner decision ${resolved.id}: no answer — default "${defaultLabel(resolved)}" applied`, true);
|
|
525
|
+
await this.tellAsker(resolved, `[DECISION ${resolved.id}] No answer by the deadline for: "${resolved.question}"${resolved.ticket ? ` (ticket ${resolved.ticket.id})` : ''}. Going with the default: "${defaultLabel(resolved)}". Act on it now.${this.whereLine(resolved)}`);
|
|
526
|
+
this.closeWatchdog(resolved);
|
|
527
|
+
this.logger.info('Owner decision defaulted at the deadline', { decisionId: resolved.id, chosen: resolved.chosenKey });
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
/** Sensitive deadline: re-ask once after 24 h, then park. Never auto-applied. */
|
|
531
|
+
async sensitiveStep(d, now) {
|
|
532
|
+
if (!d.reaskedAt) {
|
|
533
|
+
const due = Math.max(Date.parse(d.deadline), Date.parse(d.createdAt) + DECISION_CONSTANTS.SENSITIVE_REASK_AFTER_MS);
|
|
534
|
+
if (now.getTime() < due)
|
|
535
|
+
return false;
|
|
536
|
+
const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.reaskedAt ? { reaskedAt: now.toISOString() } : null));
|
|
537
|
+
if (!updated)
|
|
538
|
+
return false;
|
|
539
|
+
const owner = this.deps.ownerUserId?.();
|
|
540
|
+
await this.postInThread(updated, `${owner ? `<@${owner}> ` : ''}Still need your answer: ${updated.question} This needs your OK (${updated.sensitive}), so I'm not going ahead without it.`);
|
|
541
|
+
await this.refreshCard(updated);
|
|
542
|
+
this.logger.info('Sensitive owner decision re-asked', { decisionId: d.id });
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
if (now.getTime() < Date.parse(d.reaskedAt) + DECISION_CONSTANTS.SENSITIVE_PARK_AFTER_REASK_MS)
|
|
546
|
+
return false;
|
|
547
|
+
const parked = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' ? { status: 'parked' } : null));
|
|
548
|
+
if (!parked)
|
|
549
|
+
return false;
|
|
550
|
+
await this.refreshCard(parked);
|
|
551
|
+
if (parked.ticket)
|
|
552
|
+
await this.logTicket(parked, `owner decision ${parked.id}: no answer after a re-ask — parked (sensitive: ${parked.sensitive})`, false);
|
|
553
|
+
await this.tellAsker(parked, `[DECISION ${parked.id}] No answer to: "${parked.question}" even after a re-ask. It needs the owner's OK (${parked.sensitive}), so it is PARKED: do not do it. Move on to other work; you will get a [DECISION] message if the owner answers.`);
|
|
554
|
+
this.logger.info('Sensitive owner decision parked', { decisionId: d.id });
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// Slack helpers
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
/** Re-render the card for the decision's current state (with the posting bot's token). */
|
|
561
|
+
async refreshCard(d) {
|
|
562
|
+
const slack = this.deps.slack();
|
|
563
|
+
if (!d.card || !slack)
|
|
564
|
+
return;
|
|
565
|
+
const now = this.now();
|
|
566
|
+
const pending = d.status === 'open';
|
|
567
|
+
let ownerName;
|
|
568
|
+
if (!pending && d.answeredBy)
|
|
569
|
+
ownerName = await this.deps.userName?.(d.answeredBy).catch(() => undefined);
|
|
570
|
+
const blocks = pending ? renderOpenCard(d, this.deps.instanceId(), now) : renderSettledCard(d, ownerName, now);
|
|
571
|
+
const text = pending ? cardFallbackText(d) : `${cardFallbackText(d)} — ${settledLine(d, ownerName, now)}`;
|
|
572
|
+
const token = d.card.ownBot ? (await this.deps.identityOf(d.card.postedBy).catch(() => ({}))).botToken : undefined;
|
|
573
|
+
try {
|
|
574
|
+
await slack.updateMessage(d.card.slackChannelId, d.card.messageTs, text, blocks, token);
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
this.logger.warn('Could not update the decision card', { decisionId: d.id, error: errText(err) });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Post a line in the card's thread, as the asker. */
|
|
581
|
+
async postInThread(d, text) {
|
|
582
|
+
const slack = this.deps.slack();
|
|
583
|
+
if (!d.card || !slack || !slack.isConnected())
|
|
584
|
+
return;
|
|
585
|
+
try {
|
|
586
|
+
const identity = await this.deps.identityOf(d.asker);
|
|
587
|
+
await this.send(slack, identity, { channelId: d.card.slackChannelId, text, threadTs: d.card.threadTs ?? d.card.messageTs });
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
this.logger.warn('Could not post in the decision thread', { decisionId: d.id, error: errText(err) });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async logTicket(d, line, settled) {
|
|
594
|
+
if (!d.ticket)
|
|
595
|
+
return;
|
|
596
|
+
let clear = false;
|
|
597
|
+
if (settled) {
|
|
598
|
+
const others = await this.deps.store.list((x) => x.id !== d.id && PENDING_DECISION_STATUSES.has(x.status) && x.ticket?.projectPath === d.ticket?.projectPath && x.ticket?.id === d.ticket?.id);
|
|
599
|
+
clear = others.length === 0;
|
|
600
|
+
}
|
|
601
|
+
await this.deps.logTicket(d.ticket, line, clear).catch((err) => this.logger.warn('Could not log the decision on the ticket', { decisionId: d.id, error: errText(err) }));
|
|
602
|
+
}
|
|
603
|
+
async tellAsker(d, text) {
|
|
604
|
+
const ok = await this.deps.deliverToAgent(d.asker, text).catch(() => false);
|
|
605
|
+
if (!ok)
|
|
606
|
+
this.logger.warn('Could not deliver the decision to the asking agent', { decisionId: d.id, asker: d.asker });
|
|
607
|
+
// The orchestrator asked on the owner's behalf for a ticket it does not own: tell it too.
|
|
608
|
+
if (d.requestedBy !== d.asker && d.requestedBy === ORCHESTRATOR_SESSION_NAME) {
|
|
609
|
+
await this.deps.deliverToAgent(d.requestedBy, text).catch(() => false);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
closeWatchdog(d) {
|
|
613
|
+
if (!d.card || !this.deps.closeWatchdog)
|
|
614
|
+
return;
|
|
615
|
+
try {
|
|
616
|
+
this.deps.closeWatchdog(d.asker, d.card.slackChannelId, d.card.threadTs ?? d.card.messageTs);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
/* best-effort */
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Message of an unknown error.
|
|
625
|
+
*
|
|
626
|
+
* @param err - Thrown value
|
|
627
|
+
* @returns Text
|
|
628
|
+
*/
|
|
629
|
+
function errText(err) {
|
|
630
|
+
if (err && typeof err === 'object') {
|
|
631
|
+
const data = err.data;
|
|
632
|
+
if (typeof data?.error === 'string')
|
|
633
|
+
return data.error;
|
|
634
|
+
}
|
|
635
|
+
return err instanceof Error ? err.message : String(err);
|
|
636
|
+
}
|
|
637
|
+
/** Re-exported for the deadline line in the dashboard/API. */
|
|
638
|
+
export { formatWhen };
|
|
639
|
+
//# sourceMappingURL=decision.service.js.map
|