toolroll 0.9.17 → 0.9.18
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/CHANGELOG.md +11 -0
- package/dist/browser/workspace.js +14 -14
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-decide.d.ts +203 -0
- package/dist/chat-decide.js +554 -0
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +32 -1
- package/dist/chat-quiet.d.ts +4 -1
- package/dist/chat-quiet.js +6 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +2 -0
- package/dist/discord-api.js +3 -1
- package/dist/discord-chat.js +17 -3
- package/dist/modes.d.ts +6 -0
- package/dist/modes.js +6 -0
- package/dist/operate.js +18 -8
- package/dist/project-delete.js +3 -0
- package/dist/result-shots.d.ts +51 -0
- package/dist/result-shots.js +119 -0
- package/dist/serve.js +23 -21
- package/dist/slack-chat.js +18 -4
- package/dist/store.d.ts +24 -0
- package/dist/store.js +119 -7
- package/dist/teams-chat.js +5 -0
- package/dist/telegram-mate.d.ts +10 -0
- package/dist/telegram.d.ts +5 -3
- package/dist/telegram.js +226 -20
- package/package.json +1 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/** Decisions finish in the chat app. A pushed card about a result, a failed task, a plan waiting for approval or a
|
|
2
|
+
* pull request ready to merge carries buttons that act in place: Accept and finish, Request changes, Retry,
|
|
3
|
+
* Approve & start, Merge. Every act that changes something asks twice ("Accept and finish 'X'? [Yes] [Cancel]"),
|
|
4
|
+
* and each button is one opaque token bound to the exact result, receipt, plan or commit the card showed: a card a
|
|
5
|
+
* newer result, a finished task or a changed plan has overtaken says so and acts on nothing.
|
|
6
|
+
*
|
|
7
|
+
* Approving a plan or merging needs the password, which never goes in chat, so they act in chat only under a
|
|
8
|
+
* repository's signed mode that carries the chatApprove term, for the signer's own chat; otherwise, and for a plan
|
|
9
|
+
* that widens permissions, exceeds the mode's budget or touches protected paths, the card keeps its link.
|
|
10
|
+
*
|
|
11
|
+
* Channel-agnostic: Telegram renders these today, and Slack, Discord and Teams reach the same acts through the same
|
|
12
|
+
* tokens (the `channel` column). Every act runs through the door the console uses. */
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import { acceptAndCompleteAsOperator, assignmentOf, owedAcceptance } from "./assignment.js";
|
|
15
|
+
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
16
|
+
import { applyChatTaskAction, chatTaskStamp } from "./chat-task-actions.js";
|
|
17
|
+
import { chatTitle } from "./chat-voice.js";
|
|
18
|
+
import { readVerifiedArtifact } from "./evidence.js";
|
|
19
|
+
import { modeTermsFromJson } from "./modes.js";
|
|
20
|
+
import { parseExecutionPlanDocument } from "./plan.js";
|
|
21
|
+
import { verifyApproverStanding } from "./principal.js";
|
|
22
|
+
import { pullRequestFollowOf } from "./pull-request-flow.js";
|
|
23
|
+
import { requestResultChanges } from "./result-actions.js";
|
|
24
|
+
import { revisionSourceOf } from "./result-review.js";
|
|
25
|
+
import { phoneText } from "./telegram-status.js";
|
|
26
|
+
/** The words, said once. */
|
|
27
|
+
export const DECIDE_LABELS = {
|
|
28
|
+
accept: "Accept and finish",
|
|
29
|
+
changes: "Request changes",
|
|
30
|
+
retry: "Retry",
|
|
31
|
+
approve: "Approve & start",
|
|
32
|
+
"not-now": "Not now",
|
|
33
|
+
merge: "Merge",
|
|
34
|
+
look: "Look first",
|
|
35
|
+
edit: "Edit",
|
|
36
|
+
yes: "Yes",
|
|
37
|
+
cancel: "Cancel",
|
|
38
|
+
};
|
|
39
|
+
const OFFER_TTL_MS = 7 * 86_400_000;
|
|
40
|
+
const CONFIRM_TTL_MS = 10 * 60_000;
|
|
41
|
+
const PROMPT_TTL_MS = 30 * 60_000;
|
|
42
|
+
export const WHAT_SHOULD_CHANGE = "What should change?";
|
|
43
|
+
const sha = (text) => createHash("sha256").update(text).digest("hex");
|
|
44
|
+
const isAct = (one) => "act" in one;
|
|
45
|
+
const title = (store, taskId) => phoneText(chatTitle(store, taskId), 60);
|
|
46
|
+
// ---- the chatApprove term ----------------------------------------------------------
|
|
47
|
+
/** The live mode on this repository, when it lets this person's chat approve: the term signed, by them. */
|
|
48
|
+
export function chatApproveMode(store, repo, approver, now) {
|
|
49
|
+
const mode = repo === null ? null : store.activeMode(repo, now);
|
|
50
|
+
const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
|
|
51
|
+
if (mode === null || terms === null || !terms.chatApprove)
|
|
52
|
+
return { ok: false, why: "Approving from chat isn't turned on for this project." };
|
|
53
|
+
if (mode.signedBy !== approver || !store.accountCanAccess(approver, repo))
|
|
54
|
+
return { ok: false, why: "Only the person who signed this project's mode can approve from chat." };
|
|
55
|
+
return { ok: true, digest: mode.digest, terms };
|
|
56
|
+
}
|
|
57
|
+
/** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
|
|
58
|
+
export function permissionPlainWords(profile) {
|
|
59
|
+
if (profile === null || profile === undefined)
|
|
60
|
+
return null;
|
|
61
|
+
const full = "full access, nothing asks first";
|
|
62
|
+
if (profile.provider === "claude") {
|
|
63
|
+
return profile.permissionArgv === "bypassPermissions" ? full
|
|
64
|
+
: profile.permissionArgv === "auto" ? "file edits and routine commands; anything risky stops"
|
|
65
|
+
: "file edits only; commands are refused";
|
|
66
|
+
}
|
|
67
|
+
if (profile.provider === "gemini")
|
|
68
|
+
return profile.approvalArgv === "yolo" ? full : "file edits only; other tools are refused";
|
|
69
|
+
return profile.sandboxMode === "danger-full-access" ? full : "file edits and commands inside the project only";
|
|
70
|
+
}
|
|
71
|
+
function fullAccess(profile) {
|
|
72
|
+
if (profile === null || profile === undefined)
|
|
73
|
+
return false;
|
|
74
|
+
return profile.provider === "claude" ? profile.permissionArgv === "bypassPermissions"
|
|
75
|
+
: profile.provider === "gemini" ? profile.approvalArgv === "yolo" : profile.sandboxMode === "danger-full-access";
|
|
76
|
+
}
|
|
77
|
+
const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
|
|
78
|
+
/** A plan this person's chat may approve now: the term, the plan's exact bytes, and none of the reasons it must open
|
|
79
|
+
* Toolroll instead. `digest` binds the scope and the plan document the card shows. */
|
|
80
|
+
export function planInChat(store, taskId, approver, now, root) {
|
|
81
|
+
const ref = store.lookupRef(taskId);
|
|
82
|
+
const scope = store.getScope(taskId);
|
|
83
|
+
if (ref === null || ref.repo === null || scope === null)
|
|
84
|
+
return { ok: false, why: "This plan isn't available now." };
|
|
85
|
+
if (scope.approvedDigest != null && scope.approvedDigest === scope.digest)
|
|
86
|
+
return { ok: false, why: "This plan is already approved." };
|
|
87
|
+
if (ref.plan === "requested")
|
|
88
|
+
return { ok: false, why: "The plan is still being written." };
|
|
89
|
+
const mode = chatApproveMode(store, ref.repo, approver, now);
|
|
90
|
+
if (!mode.ok)
|
|
91
|
+
return mode;
|
|
92
|
+
if (store.getTask(taskId)?.state === "cancelled" || store.getTask(taskId)?.state === "done")
|
|
93
|
+
return { ok: false, why: "This task is already finished." };
|
|
94
|
+
const family = store.taskFamilyOf(taskId, [ref.repo], false);
|
|
95
|
+
if (family === null || family.problem !== null || family.current.id !== taskId)
|
|
96
|
+
return { ok: false, why: "A newer version of this task is current." };
|
|
97
|
+
// What the mode's seal refuses anyway, said first: model- or coordinator-written terms, a race, an unreadable route.
|
|
98
|
+
if (ref.coordinatorCid !== null || (scope.proposedVia ?? null) !== null)
|
|
99
|
+
return { ok: false, why: "This plan was written for you, so you approve it in Toolroll." };
|
|
100
|
+
if (store.activeTournamentTerms(ref.id) !== null)
|
|
101
|
+
return { ok: false, why: "Agents compete on this task, so you approve it in Toolroll." };
|
|
102
|
+
if (scope.profileState === "unresolved" || scope.routeEra == null || (scope.termsProblem ?? null) !== null)
|
|
103
|
+
return { ok: false, why: "This plan can't say exactly which agents run, so you approve it in Toolroll." };
|
|
104
|
+
if (fullAccess(scope.profile) && mode.terms.permissionDefault !== "escalated")
|
|
105
|
+
return { ok: false, why: "This plan asks for more access than your mode allows." };
|
|
106
|
+
const cap = mode.terms.perAttemptBudgetMicrousd;
|
|
107
|
+
if (cap !== null && (scope.budgetMicrousd === null || scope.budgetMicrousd > cap))
|
|
108
|
+
return { ok: false, why: `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than your mode's ${money(cap)}.` };
|
|
109
|
+
if (store.approvalGate(taskId, approver, "mode").verdict !== "seal")
|
|
110
|
+
return { ok: false, why: "This plan touches protected work, so two people approve it in Toolroll." };
|
|
111
|
+
if (store.scopePolicyRefusal(taskId) !== null)
|
|
112
|
+
return { ok: false, why: "Your organisation's policy needs this plan approved in Toolroll." };
|
|
113
|
+
// A revision approves only against notes that still verify, as on the console.
|
|
114
|
+
if (ref.revisionBriefArtifact !== null) {
|
|
115
|
+
const brief = store.getArtifact(ref.revisionBriefArtifact);
|
|
116
|
+
let verified = false;
|
|
117
|
+
try {
|
|
118
|
+
verified = brief !== null && root !== undefined && readVerifiedArtifact(root, brief).ok;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
verified = false;
|
|
122
|
+
}
|
|
123
|
+
if (!verified)
|
|
124
|
+
return { ok: false, why: "This revision's notes can't be verified here, so you approve it in Toolroll." };
|
|
125
|
+
}
|
|
126
|
+
// The plan the card shows must be the saved one, verified, as the console's approval reads it.
|
|
127
|
+
const plan = store.latestPlanArtifact(ref.id);
|
|
128
|
+
if (plan !== null) {
|
|
129
|
+
let verified = false;
|
|
130
|
+
try {
|
|
131
|
+
verified = root !== undefined && readVerifiedArtifact(root, plan).ok;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
verified = false;
|
|
135
|
+
}
|
|
136
|
+
if (!verified)
|
|
137
|
+
return { ok: false, why: "The saved plan can't be verified here, so you approve it in Toolroll." };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, modeDigest: mode.digest, scope, digest: sha(`${scope.digest}:${plan?.sha256 ?? ""}`) };
|
|
140
|
+
}
|
|
141
|
+
/** A ready pull request this person's chat may merge now, bound to the exact commit. */
|
|
142
|
+
export function mergeInChat(store, taskId, run, approver, now) {
|
|
143
|
+
const ref = store.lookupRef(taskId);
|
|
144
|
+
const publication = store.publicationForRun(run);
|
|
145
|
+
const follow = publication === null ? null : pullRequestFollowOf(store, publication.id);
|
|
146
|
+
if (ref === null || ref.repo === null || publication === null || follow === null || publication.prNumber === null)
|
|
147
|
+
return { ok: false, why: "This result has no pull request to merge." };
|
|
148
|
+
if (follow.mergeCommit !== null)
|
|
149
|
+
return { ok: false, why: "This pull request is already merged." };
|
|
150
|
+
if (follow.readyHead !== publication.headSha)
|
|
151
|
+
return { ok: false, why: "This pull request changed since this card was sent." };
|
|
152
|
+
const mode = chatApproveMode(store, ref.repo, approver, now);
|
|
153
|
+
if (!mode.ok)
|
|
154
|
+
return mode;
|
|
155
|
+
return { ok: true, modeDigest: mode.digest, head: publication.headSha };
|
|
156
|
+
}
|
|
157
|
+
/** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. */
|
|
158
|
+
export function planCardText(store, taskId, scope, root) {
|
|
159
|
+
const ref = store.lookupRef(taskId);
|
|
160
|
+
const artifact = ref === null ? null : store.latestPlanArtifact(ref.id);
|
|
161
|
+
let milestones = [];
|
|
162
|
+
if (artifact !== null && root !== undefined) {
|
|
163
|
+
try {
|
|
164
|
+
const read = readVerifiedArtifact(root, artifact);
|
|
165
|
+
const parsed = read.ok ? parseExecutionPlanDocument(read.content.toString("utf8")) : null;
|
|
166
|
+
if (parsed?.ok)
|
|
167
|
+
milestones = parsed.document.milestones;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
milestones = [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const bullets = (items, cap) => [...items.slice(0, cap).map(one => `• ${phoneText(one, 120)}`), ...(items.length > cap ? [`• and ${items.length - cap} more`] : [])];
|
|
174
|
+
// The goal in two lines at most: its first two, each kept short.
|
|
175
|
+
const goal = scope.goal.split("\n").filter(one => one.trim() !== "").slice(0, 2).map(one => phoneText(one, 160));
|
|
176
|
+
const allowing = [permissionPlainWords(scope.profile), scope.budgetMicrousd === null ? "no attempt limit" : `up to ${money(scope.budgetMicrousd)} per attempt`]
|
|
177
|
+
.filter((one) => one !== null);
|
|
178
|
+
return [
|
|
179
|
+
`Plan ready: ${title(store, taskId)}`,
|
|
180
|
+
"",
|
|
181
|
+
...goal,
|
|
182
|
+
"",
|
|
183
|
+
"Changes:",
|
|
184
|
+
...(milestones.length > 0 ? bullets(milestones, 5) : []),
|
|
185
|
+
...(scope.touches.length > 0 ? [`Only in: ${scope.touches.slice(0, 6).map(one => phoneText(one, 80)).join(", ")}${scope.touches.length > 6 ? ", …" : ""}`] : milestones.length > 0 ? [] : ["Any file in the project."]),
|
|
186
|
+
"",
|
|
187
|
+
"Done when:",
|
|
188
|
+
...(scope.acceptance.length > 0 ? bullets(scope.acceptance.map(one => one.statement), 5) : ["You decide when you review the result."]),
|
|
189
|
+
"",
|
|
190
|
+
`You're allowing: ${allowing.join(" · ")}`,
|
|
191
|
+
].join("\n");
|
|
192
|
+
}
|
|
193
|
+
// ---- what a card offers ---------------------------------------------------------------
|
|
194
|
+
/** The acts a card about this target can take in chat right now, or null when none can (the card keeps its own
|
|
195
|
+
* link). Reads only; nothing is minted. */
|
|
196
|
+
export function decideOffer(store, target, who, now, root) {
|
|
197
|
+
const ref = store.lookupRef(target.taskId);
|
|
198
|
+
if (ref === null || ref.repo === null || !who.repos.includes(ref.repo))
|
|
199
|
+
return null;
|
|
200
|
+
switch (target.kind) {
|
|
201
|
+
case "result": {
|
|
202
|
+
// Accepting and requesting changes read the saved result: without it, the card keeps its link.
|
|
203
|
+
if (root === undefined)
|
|
204
|
+
return null;
|
|
205
|
+
const fresh = resultFresh(store, target.taskId, target.run, who, now, root);
|
|
206
|
+
if (!fresh.ok)
|
|
207
|
+
return null;
|
|
208
|
+
const look = { label: DECIDE_LABELS.look, path: chatResultHref(target.taskId, target.run, "changes") };
|
|
209
|
+
// A result accepted only with a reason ("Accept anyway") keeps its link: the reason is typed on the result.
|
|
210
|
+
if (fresh.exception)
|
|
211
|
+
return null;
|
|
212
|
+
return { text: null, rows: [[{ act: "accept", run: target.run, digest: fresh.receipt }, { act: "changes", run: target.run, digest: fresh.source }], [look]] };
|
|
213
|
+
}
|
|
214
|
+
case "failed": {
|
|
215
|
+
const stamp = retryStamp(store, target.taskId, who);
|
|
216
|
+
if (stamp === null)
|
|
217
|
+
return null;
|
|
218
|
+
return { text: null, rows: [[{ act: "retry", run: null, digest: stamp }], [{ label: DECIDE_LABELS.look, path: chatControlHref("task", target.taskId) }]] };
|
|
219
|
+
}
|
|
220
|
+
case "plan": {
|
|
221
|
+
const plan = planInChat(store, target.taskId, who.name, now, root);
|
|
222
|
+
if (!plan.ok)
|
|
223
|
+
return null;
|
|
224
|
+
return { text: planCardText(store, target.taskId, plan.scope, root),
|
|
225
|
+
rows: [[{ act: "approve", run: null, digest: plan.digest }], [{ label: DECIDE_LABELS.edit, path: chatControlHref("approval", target.taskId) }, { act: "not-now", run: null, digest: plan.digest }]] };
|
|
226
|
+
}
|
|
227
|
+
case "merge": {
|
|
228
|
+
const merge = mergeInChat(store, target.taskId, target.run, who.name, now);
|
|
229
|
+
if (!merge.ok)
|
|
230
|
+
return null;
|
|
231
|
+
return { text: null, rows: [[{ act: "merge", run: target.run, digest: merge.head }], [{ label: DECIDE_LABELS.look, path: chatResultHref(target.taskId, target.run, "changes") }]] };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Is the result the card showed still the one waiting to be finished? */
|
|
236
|
+
function resultFresh(store, taskId, run, who, now, root) {
|
|
237
|
+
const assignment = assignmentOf(store, taskId, now, { principal: "operator", repos: who.repos }, root);
|
|
238
|
+
if (assignment === null)
|
|
239
|
+
return { ok: false, why: "This result isn't available now." };
|
|
240
|
+
if (assignment.state === "complete")
|
|
241
|
+
return { ok: false, why: "This task is already finished." };
|
|
242
|
+
if (assignment.state === "cancelled")
|
|
243
|
+
return { ok: false, why: "This task was cancelled." };
|
|
244
|
+
const receipt = assignment.receipt;
|
|
245
|
+
if (receipt === null || receipt.taskId !== taskId || receipt.runId !== run)
|
|
246
|
+
return { ok: false, why: "A newer result is current." };
|
|
247
|
+
if (assignment.state !== "ready-to-check")
|
|
248
|
+
return { ok: false, why: "This result isn't ready to finish now." };
|
|
249
|
+
return { ok: true, receipt: receipt.digest, source: revisionSourceOf(store.getScope(taskId)?.digest ?? null), exception: owedAcceptance(receipt) === "exception" };
|
|
250
|
+
}
|
|
251
|
+
function retryStamp(store, taskId, who) {
|
|
252
|
+
if (store.getTask(taskId)?.state !== "failed")
|
|
253
|
+
return null;
|
|
254
|
+
const stamp = chatTaskStamp(store, who, taskId);
|
|
255
|
+
return stamp === null ? null : sha(stamp);
|
|
256
|
+
}
|
|
257
|
+
/** One fingerprint of what the card offers, without its tokens: a repaint with the same offer keeps its buttons. */
|
|
258
|
+
export function offerFingerprint(offer) {
|
|
259
|
+
return sha(JSON.stringify(offer));
|
|
260
|
+
}
|
|
261
|
+
function readAction(row) {
|
|
262
|
+
return { token: String(row["token"]), channel: String(row["channel"]), binding: Number(row["binding"]), chat: String(row["chat"]),
|
|
263
|
+
message: row["message"] == null ? null : String(row["message"]), act: String(row["act"]), phase: String(row["phase"]),
|
|
264
|
+
taskId: String(row["task_id"]), run: row["run"] == null ? null : Number(row["run"]), digest: String(row["digest"]),
|
|
265
|
+
expiresAt: String(row["expires_at"]), consumedAt: row["consumed_at"] == null ? null : String(row["consumed_at"]) };
|
|
266
|
+
}
|
|
267
|
+
function mint(store, seat, message, spec, taskId, phase, now) {
|
|
268
|
+
const token = `d:${randomBytes(12).toString("hex")}`;
|
|
269
|
+
store.handle.prepare(`INSERT INTO chat_decide_action (token, channel, binding, chat, message, act, phase, task_id, run, digest, created_at, expires_at)
|
|
270
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(token, seat.channel, seat.binding, seat.chat, message, spec.act, phase, taskId, spec.run, spec.digest, now.toISOString(), new Date(now.getTime() + (phase === "offer" ? OFFER_TTL_MS : CONFIRM_TTL_MS)).toISOString());
|
|
271
|
+
return token;
|
|
272
|
+
}
|
|
273
|
+
/** The card's buttons, minted now (message unknown until it is sent: `placeDecideTokens` puts them on it). */
|
|
274
|
+
export function mintDecideButtons(store, seat, target, offer, now, message = null) {
|
|
275
|
+
const tokens = [];
|
|
276
|
+
const rows = offer.rows.map(row => row.map((one) => {
|
|
277
|
+
if (!isAct(one))
|
|
278
|
+
return { label: one.label, link: one };
|
|
279
|
+
const token = mint(store, seat, message, one, target.taskId, "offer", now);
|
|
280
|
+
tokens.push(token);
|
|
281
|
+
return { label: DECIDE_LABELS[one.act], token };
|
|
282
|
+
}));
|
|
283
|
+
return { rows, tokens };
|
|
284
|
+
}
|
|
285
|
+
/** Put freshly minted tokens on the message they ride: a tap on any other message proves itself stale. */
|
|
286
|
+
export function placeDecideTokens(store, tokens, message) {
|
|
287
|
+
const place = store.handle.prepare("UPDATE chat_decide_action SET message = ? WHERE token = ? AND message IS NULL");
|
|
288
|
+
for (const token of tokens)
|
|
289
|
+
place.run(message, token);
|
|
290
|
+
}
|
|
291
|
+
/** Every live button on one message stops working: the card was repainted, or one of its acts was taken. */
|
|
292
|
+
export function retireDecideTokens(store, channel, chat, message, now) {
|
|
293
|
+
store.handle.prepare("UPDATE chat_decide_action SET consumed_at = ? WHERE channel = ? AND chat = ? AND message = ? AND consumed_at IS NULL")
|
|
294
|
+
.run(now.toISOString(), channel, chat, message);
|
|
295
|
+
}
|
|
296
|
+
/** Buttons minted for a send or an edit that didn't land: they never act. */
|
|
297
|
+
export function dropDecideTokens(store, tokens, now) {
|
|
298
|
+
const drop = store.handle.prepare("UPDATE chat_decide_action SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL");
|
|
299
|
+
for (const token of tokens)
|
|
300
|
+
drop.run(now.toISOString(), token);
|
|
301
|
+
}
|
|
302
|
+
/** Whether a message still carries a button that acts (an offer, or an armed Yes or Cancel): a card whose acts were
|
|
303
|
+
* all spent, as by Not now, needs fresh ones. */
|
|
304
|
+
export function hasLiveDecideTokens(store, channel, chat, message, now) {
|
|
305
|
+
return store.handle.prepare("SELECT 1 FROM chat_decide_action WHERE channel = ? AND chat = ? AND message = ? AND consumed_at IS NULL AND expires_at > ? LIMIT 1")
|
|
306
|
+
.get(channel, chat, message, now.toISOString()) !== undefined;
|
|
307
|
+
}
|
|
308
|
+
export function isDecideToken(store, token) {
|
|
309
|
+
return token.startsWith("d:") && store.handle.prepare("SELECT 1 FROM chat_decide_action WHERE token = ?").get(token) !== undefined;
|
|
310
|
+
}
|
|
311
|
+
const QUESTIONS = {
|
|
312
|
+
accept: name => `Accept and finish "${name}"?`,
|
|
313
|
+
retry: name => `Retry "${name}"? It queues another attempt.`,
|
|
314
|
+
approve: name => `Approve and start "${name}"?`,
|
|
315
|
+
merge: name => `Merge "${name}"?`,
|
|
316
|
+
};
|
|
317
|
+
function targetOf(row) {
|
|
318
|
+
switch (row.act) {
|
|
319
|
+
case "accept":
|
|
320
|
+
case "changes": return { kind: "result", taskId: row.taskId, run: row.run ?? 0 };
|
|
321
|
+
case "retry": return { kind: "failed", taskId: row.taskId };
|
|
322
|
+
case "merge": return { kind: "merge", taskId: row.taskId, run: row.run ?? 0 };
|
|
323
|
+
default: return { kind: "plan", taskId: row.taskId };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** Why the card's act no longer applies, or null while it still does — checked at the offer and again at the yes. */
|
|
327
|
+
function staleWhy(store, row, who, now, root) {
|
|
328
|
+
switch (row.act) {
|
|
329
|
+
case "accept":
|
|
330
|
+
case "changes": {
|
|
331
|
+
const fresh = resultFresh(store, row.taskId, row.run ?? 0, who, now, root);
|
|
332
|
+
if (!fresh.ok)
|
|
333
|
+
return fresh.why;
|
|
334
|
+
if ((row.act === "accept" ? fresh.receipt : fresh.source) !== row.digest)
|
|
335
|
+
return "This result changed since this card was sent.";
|
|
336
|
+
return row.act === "accept" && fresh.exception ? "This result needs a reason to accept. Open it in Toolroll." : null;
|
|
337
|
+
}
|
|
338
|
+
case "retry": {
|
|
339
|
+
if (store.getTask(row.taskId)?.state !== "failed")
|
|
340
|
+
return "This task isn't failed any more.";
|
|
341
|
+
return retryStamp(store, row.taskId, who) === row.digest ? null : "This task changed since this card was sent.";
|
|
342
|
+
}
|
|
343
|
+
case "approve":
|
|
344
|
+
case "not-now": {
|
|
345
|
+
const plan = planInChat(store, row.taskId, who.name, now, root);
|
|
346
|
+
if (!plan.ok)
|
|
347
|
+
return plan.why;
|
|
348
|
+
return plan.digest === row.digest ? null : "The plan changed since this card was sent.";
|
|
349
|
+
}
|
|
350
|
+
case "merge": {
|
|
351
|
+
const merge = mergeInChat(store, row.taskId, row.run ?? 0, who.name, now);
|
|
352
|
+
if (!merge.ok)
|
|
353
|
+
return merge.why;
|
|
354
|
+
return merge.head === row.digest ? null : "This pull request changed since this card was sent.";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** The links a card keeps once its acts are spent or stale: where to look, never a token. */
|
|
359
|
+
export function linksFor(target) {
|
|
360
|
+
switch (target.kind) {
|
|
361
|
+
case "result": return [[{ label: DECIDE_LABELS.look, link: { label: DECIDE_LABELS.look, path: chatResultHref(target.taskId, target.run, "changes") } }]];
|
|
362
|
+
case "merge": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
|
|
363
|
+
case "plan": return [[{ label: "Review & start", link: { label: "Review & start", path: chatControlHref("approval", target.taskId) } }]];
|
|
364
|
+
case "failed": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const QUESTION_STARTS = ["Accept and finish \"", "Retry \"", "Approve and start \"", "Merge \""];
|
|
368
|
+
/** The card's words without a question this module appended. */
|
|
369
|
+
function cardBody(shown) {
|
|
370
|
+
const at = shown.lastIndexOf("\n\n");
|
|
371
|
+
return at >= 0 && QUESTION_STARTS.some(start => shown.startsWith(start, at + 2)) ? shown.slice(0, at) : shown;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* One tap on a decide button, inside the caller's transaction. Null: not this module's token. The seat must be the
|
|
375
|
+
* token's own (person, chat, message); a spent, expired or foreign button acts on nothing.
|
|
376
|
+
*/
|
|
377
|
+
export function applyDecideTap(store, seat, input) {
|
|
378
|
+
const raw = input.token.startsWith("d:") ? store.handle.prepare("SELECT * FROM chat_decide_action WHERE token = ?").get(input.token) : undefined;
|
|
379
|
+
if (raw === undefined)
|
|
380
|
+
return null;
|
|
381
|
+
const row = readAction(raw);
|
|
382
|
+
const { now } = input;
|
|
383
|
+
if (row.channel !== seat.channel || row.binding !== seat.binding || row.chat !== seat.chat || (row.message !== null && row.message !== input.message))
|
|
384
|
+
return { ack: "That button isn't for this chat.", ignored: true };
|
|
385
|
+
if (row.consumedAt !== null)
|
|
386
|
+
return { ack: "That button was already used or has expired." };
|
|
387
|
+
const verified = verifyApproverStanding(store, seat.approver, seat.generation, input.repos);
|
|
388
|
+
const ref = store.lookupRef(row.taskId);
|
|
389
|
+
if (!verified.ok || ref === null || ref.repo === null || !verified.who.repos.includes(ref.repo))
|
|
390
|
+
return { ack: "That task isn't available here now.", ignored: true };
|
|
391
|
+
const who = verified.who;
|
|
392
|
+
const target = targetOf(row);
|
|
393
|
+
const name = title(store, row.taskId);
|
|
394
|
+
const body = cardBody(input.shown);
|
|
395
|
+
const spend = () => {
|
|
396
|
+
// This token by its own id (it may never have been placed on a message), then every other button on the card.
|
|
397
|
+
store.handle.prepare("UPDATE chat_decide_action SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL").run(now.toISOString(), row.token);
|
|
398
|
+
retireDecideTokens(store, seat.channel, seat.chat, input.message, now);
|
|
399
|
+
};
|
|
400
|
+
const stale = (why) => {
|
|
401
|
+
spend();
|
|
402
|
+
return { ack: why, edit: { text: `${body}\n\n${why} Nothing was done.`, rows: linksFor(target), tokens: [] } };
|
|
403
|
+
};
|
|
404
|
+
// An expired button: the card says so and keeps its links, so it is never a dead end.
|
|
405
|
+
if (row.expiresAt <= now.toISOString()) {
|
|
406
|
+
spend();
|
|
407
|
+
return { ack: "That button expired.", edit: { text: `${body}\n\nThat button expired. Nothing was done.`, rows: linksFor(target), tokens: [] } };
|
|
408
|
+
}
|
|
409
|
+
if (row.phase === "cancel") {
|
|
410
|
+
spend();
|
|
411
|
+
const offer = decideOffer(store, target, who, now, input.root);
|
|
412
|
+
if (offer === null)
|
|
413
|
+
return { ack: "Cancelled.", edit: { text: body, rows: linksFor(target), tokens: [] } };
|
|
414
|
+
const minted = mintDecideButtons(store, seat, target, offer, now, input.message);
|
|
415
|
+
return { ack: "Cancelled.", edit: { text: offer.text ?? body, ...minted } };
|
|
416
|
+
}
|
|
417
|
+
const why = staleWhy(store, row, who, now, input.root);
|
|
418
|
+
if (why !== null)
|
|
419
|
+
return stale(why);
|
|
420
|
+
if (row.phase === "offer") {
|
|
421
|
+
if (row.act === "changes") {
|
|
422
|
+
const id = openDecidePrompt(store, seat, row.taskId, row.run ?? 0, row.digest, now);
|
|
423
|
+
return { ack: WHAT_SHOULD_CHANGE, prompt: { id, text: `${WHAT_SHOULD_CHANGE} Your next message is the feedback for "${name}".` } };
|
|
424
|
+
}
|
|
425
|
+
if (row.act === "not-now") {
|
|
426
|
+
spend();
|
|
427
|
+
return { ack: "Okay, it waits.", edit: { text: `${body}\n\nNot now. It waits for you in Tasks.`,
|
|
428
|
+
rows: [[{ label: DECIDE_LABELS.edit, link: { label: DECIDE_LABELS.edit, path: chatControlHref("approval", row.taskId) } }]], tokens: [] } };
|
|
429
|
+
}
|
|
430
|
+
// The first tap asks; only the yes acts, bound to exactly what this button was.
|
|
431
|
+
spend();
|
|
432
|
+
const spec = { act: row.act, run: row.run, digest: row.digest };
|
|
433
|
+
const yes = mint(store, seat, input.message, spec, row.taskId, "yes", now);
|
|
434
|
+
const cancel = mint(store, seat, input.message, spec, row.taskId, "cancel", now);
|
|
435
|
+
return { ack: "Confirm below.", edit: { text: `${body}\n\n${QUESTIONS[row.act](name)}`,
|
|
436
|
+
rows: [[{ label: DECIDE_LABELS.yes, token: yes }, { label: DECIDE_LABELS.cancel, token: cancel }]], tokens: [yes, cancel] } };
|
|
437
|
+
}
|
|
438
|
+
// The yes: the act itself, through the door the console uses.
|
|
439
|
+
spend();
|
|
440
|
+
const done = (said, rows = []) => ({ ack: said, edit: { text: `${body}\n\n${said}`, rows, tokens: [] } });
|
|
441
|
+
const notDone = (said) => ({ ack: "Not done.", edit: { text: `${body}\n\n✗ Not done: ${said}`, rows: linksFor(target), tokens: [] } });
|
|
442
|
+
switch (row.act) {
|
|
443
|
+
case "accept": {
|
|
444
|
+
if (input.root === undefined)
|
|
445
|
+
return notDone("this chat can't read the saved result. Open it in Toolroll.");
|
|
446
|
+
const finished = acceptAndCompleteAsOperator(store, row.taskId, { runId: row.run, receiptDigest: row.digest, note: null }, who, now, input.root);
|
|
447
|
+
return finished.ok ? done("✓ Accepted and finished. The recorded checks are unchanged.") : notDone(finished.message);
|
|
448
|
+
}
|
|
449
|
+
case "retry": {
|
|
450
|
+
const stamp = chatTaskStamp(store, who, row.taskId);
|
|
451
|
+
const retried = applyChatTaskAction(store, who, { task: row.taskId, operation: "retry", stamp }, now, true, input.root === undefined ? {} : { evidenceRoot: input.root });
|
|
452
|
+
return retried.ok ? done("✓ Queued again. Existing approvals and holds still apply.") : notDone(retried.message);
|
|
453
|
+
}
|
|
454
|
+
case "approve": {
|
|
455
|
+
const plan = planInChat(store, row.taskId, who.name, now, input.root);
|
|
456
|
+
if (!plan.ok)
|
|
457
|
+
return notDone(plan.why);
|
|
458
|
+
if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "mode", modeDigest: plan.modeDigest }))
|
|
459
|
+
return notDone("the plan couldn't be approved from chat. Open it in Toolroll.");
|
|
460
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "plan approved in chat", outcome: "approved",
|
|
461
|
+
source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · mode ${plan.modeDigest}` });
|
|
462
|
+
return done("✓ Approved under your chat approval mode. Work starts when a worker is free.");
|
|
463
|
+
}
|
|
464
|
+
case "merge": {
|
|
465
|
+
const merge = mergeInChat(store, row.taskId, row.run ?? 0, who.name, now);
|
|
466
|
+
if (!merge.ok)
|
|
467
|
+
return notDone(merge.why);
|
|
468
|
+
const via = `via ${seat.channel} · chat binding #${seat.binding} · mode ${merge.modeDigest} · ${merge.head.slice(0, 12)}`;
|
|
469
|
+
// The approval is ledgered with the tap, so a bridge that stops before GitHub answers still leaves it on record;
|
|
470
|
+
// what GitHub then did is its own line (recordChatMerge).
|
|
471
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: row.run ?? null, action: "merge approved in chat", outcome: "approved",
|
|
472
|
+
source: "request", detail: via });
|
|
473
|
+
return { ack: "Merging…", edit: { text: `${body}\n\nMerging…`, rows: [], tokens: [] }, merge: { runId: row.run ?? 0, taskId: row.taskId, by: who.name, repo: ref.repo, via } };
|
|
474
|
+
}
|
|
475
|
+
default:
|
|
476
|
+
return { ack: "That button doesn't do anything now.", ignored: true };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** A chat merge's second ledger line, after the GitHub call: merged, or failed and why (its approval is the first). */
|
|
480
|
+
export function recordChatMerge(store, merge, result, now) {
|
|
481
|
+
store.recordAction({ at: now.toISOString(), actor: merge.by, repo: merge.repo, taskId: merge.taskId, runId: merge.runId, action: "merge from chat",
|
|
482
|
+
outcome: result.ok ? "merged" : "failed", source: "request", detail: result.ok ? merge.via : `${merge.via} · ${phoneText(result.message, 160)}` });
|
|
483
|
+
}
|
|
484
|
+
/** What the card says once the merge came back. */
|
|
485
|
+
export function mergedText(shown, result) {
|
|
486
|
+
const body = shown.replace(/\n\nMerging…$/, "");
|
|
487
|
+
return result.ok ? `${body}\n\n✓ Merged.` : `${body}\n\n✗ Not merged: ${result.message}`;
|
|
488
|
+
}
|
|
489
|
+
/** One open "What should change?" per person and chat: a newer one replaces it. */
|
|
490
|
+
export function openDecidePrompt(store, seat, taskId, run, digest, now) {
|
|
491
|
+
store.handle.prepare("UPDATE chat_decide_prompt SET consumed_at = ? WHERE channel = ? AND binding = ? AND chat = ? AND consumed_at IS NULL")
|
|
492
|
+
.run(now.toISOString(), seat.channel, seat.binding, seat.chat);
|
|
493
|
+
return Number(store.handle.prepare("INSERT INTO chat_decide_prompt (channel, binding, chat, task_id, run, digest, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?)")
|
|
494
|
+
.run(seat.channel, seat.binding, seat.chat, taskId, run, digest, now.toISOString(), new Date(now.getTime() + PROMPT_TTL_MS).toISOString()).lastInsertRowid);
|
|
495
|
+
}
|
|
496
|
+
/** The message that asked "What should change?", once it was sent. */
|
|
497
|
+
export function placeDecidePrompt(store, id, message) {
|
|
498
|
+
store.handle.prepare("UPDATE chat_decide_prompt SET message = ? WHERE id = ? AND message IS NULL").run(message, id);
|
|
499
|
+
}
|
|
500
|
+
export function openPromptFor(store, seat, now) {
|
|
501
|
+
const row = store.handle.prepare("SELECT * FROM chat_decide_prompt WHERE channel = ? AND binding = ? AND chat = ? AND consumed_at IS NULL AND expires_at > ? ORDER BY id DESC LIMIT 1")
|
|
502
|
+
.get(seat.channel, seat.binding, seat.chat, now.toISOString());
|
|
503
|
+
return row === undefined ? null : { id: Number(row["id"]), taskId: String(row["task_id"]), run: Number(row["run"]), digest: String(row["digest"]),
|
|
504
|
+
message: row["message"] == null ? null : String(row["message"]) };
|
|
505
|
+
}
|
|
506
|
+
/** Whether this message answers the prompt: the person's next message, unless it replies to some other message. */
|
|
507
|
+
export function answersPrompt(prompt, replyTo) {
|
|
508
|
+
return replyTo === null || (prompt.message !== null && prompt.message === replyTo);
|
|
509
|
+
}
|
|
510
|
+
/** The person's message after "What should change?": a revision of the exact result, with these words as its
|
|
511
|
+
* feedback, through the result page's own service. Says what was sent, once. */
|
|
512
|
+
export function applyDecideFeedback(store, seat, prompt, text, repos, root, now) {
|
|
513
|
+
store.handle.prepare("UPDATE chat_decide_prompt SET consumed_at = ? WHERE id = ?").run(now.toISOString(), prompt.id);
|
|
514
|
+
const name = title(store, prompt.taskId);
|
|
515
|
+
const verified = verifyApproverStanding(store, seat.approver, seat.generation, repos);
|
|
516
|
+
if (!verified.ok)
|
|
517
|
+
return { said: "That result isn't available here now. Nothing was sent.", link: null };
|
|
518
|
+
const why = staleWhy(store, { act: "changes", taskId: prompt.taskId, run: prompt.run, digest: prompt.digest }, verified.who, now, root);
|
|
519
|
+
if (why !== null)
|
|
520
|
+
return { said: `${why} Your feedback wasn't sent.`, link: { label: "Open task", path: chatControlHref("task", prompt.taskId) } };
|
|
521
|
+
if (root === undefined)
|
|
522
|
+
return { said: "This chat can't read the saved result, so your feedback wasn't sent. Open it in Toolroll.", link: null };
|
|
523
|
+
const revised = requestResultChanges(store, root, {
|
|
524
|
+
run: prompt.run, batch: "", source: prompt.digest, actor: verified.who.name, repos: verified.who.repos,
|
|
525
|
+
note: text, path: "", line: "", request: sha(`chat-decide:${seat.channel}:${prompt.id}`).slice(0, 32), allowMode: true,
|
|
526
|
+
}, now);
|
|
527
|
+
if (!revised.ok)
|
|
528
|
+
return { said: `Your feedback wasn't sent: ${revised.message}`, link: null };
|
|
529
|
+
const quoted = phoneText(text, 1_000).split("\n").map(line => `| ${line}`).join("\n");
|
|
530
|
+
const scope = store.getScope(revised.id);
|
|
531
|
+
const approved = scope !== null && scope.approvedDigest != null && scope.approvedDigest === scope.digest;
|
|
532
|
+
return {
|
|
533
|
+
said: `Changes requested for "${name}":\n${quoted}\n\n${approved ? "The revision starts under your automatic approval settings." : "The revision waits for your approval."}`,
|
|
534
|
+
link: approved ? { label: "Open task", path: chatControlHref("task", revised.id) } : { label: "Review & start", path: chatControlHref("approval", revised.id) },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/** The link a card keeps when it can't act in chat: a plan's is Review & start, where the password step lives. */
|
|
538
|
+
export function decideFallbackLink(target) {
|
|
539
|
+
return target?.kind === "plan" ? { label: "Review & start", path: chatControlHref("approval", target.taskId) } : null;
|
|
540
|
+
}
|
|
541
|
+
/** Which card a pushed notification is, from its recorded kind and task — never from its words. */
|
|
542
|
+
export function decideTargetOf(row) {
|
|
543
|
+
if (row.taskId === null)
|
|
544
|
+
return null;
|
|
545
|
+
if (row.kind === "acceptance-ready" && row.run !== null)
|
|
546
|
+
return { kind: "result", taskId: row.taskId, run: row.run };
|
|
547
|
+
if (row.kind === "plan-ready")
|
|
548
|
+
return { kind: "plan", taskId: row.taskId };
|
|
549
|
+
if (row.kind === "pull-request-ready" && row.run !== null)
|
|
550
|
+
return { kind: "merge", taskId: row.taskId, run: row.run };
|
|
551
|
+
if (/fail|exhausted|stalled|fenced/.test(row.kind))
|
|
552
|
+
return { kind: "failed", taskId: row.taskId };
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
@@ -68,6 +68,11 @@ export type ChatContent = {
|
|
|
68
68
|
artifact: number;
|
|
69
69
|
sha256: string;
|
|
70
70
|
};
|
|
71
|
+
/** A screenshot sent with a result (result-shots.ts): it follows that result's message part, in its thread when
|
|
72
|
+
* the app allows; a refused upload becomes one plain line, and one removed by retention goes quietly. */
|
|
73
|
+
shot?: {
|
|
74
|
+
follows: number | null;
|
|
75
|
+
};
|
|
71
76
|
task?: string;
|
|
72
77
|
run?: number;
|
|
73
78
|
edit?: string;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -19,7 +19,8 @@ import { leadChannelOf } from "./lead-context.js";
|
|
|
19
19
|
import { promiseChannelOf } from "./lead-commitments.js";
|
|
20
20
|
import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
21
21
|
import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
|
|
22
|
-
import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
|
|
22
|
+
import { isTelegramProgressNotification, proposalTaskOf, RESULT_SHOTS_KIND } from "./store.js";
|
|
23
|
+
import { resultShotsFor } from "./result-shots.js";
|
|
23
24
|
import { messageTeammate } from "./teammate-desk.js";
|
|
24
25
|
import { CHAT_APP_NAMES } from "./flow-triggers.js";
|
|
25
26
|
export const chatObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
@@ -668,6 +669,32 @@ function planFinished(options, binding, taskRef, run, now, notification = null)
|
|
|
668
669
|
store.setChatBatchMessage(batch.id, String(part.id), shown);
|
|
669
670
|
return true;
|
|
670
671
|
}
|
|
672
|
+
/** Screenshots with a result (result-shots.ts), planned after the result's own message: each an upload that follows
|
|
673
|
+
* it in its thread (Slack, Discord), or one link to them (Teams). A failed check is one plain line instead. */
|
|
674
|
+
function planResultShots(options, binding, notification, repos, id, now) {
|
|
675
|
+
const { state, store, identity } = options;
|
|
676
|
+
const plan = resultShotsFor(store, options.evidenceRoot, repos, notification);
|
|
677
|
+
if (plan.kind === "none" || notification.taskId === null || notification.run === null)
|
|
678
|
+
return;
|
|
679
|
+
const where = { task: notification.taskId, run: notification.run };
|
|
680
|
+
// The result's message here: its finished-work batch (quiet), or its progress card (every step).
|
|
681
|
+
const follows = (() => {
|
|
682
|
+
const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.channel}:${binding.id}`, notification.taskRef, notification.run);
|
|
683
|
+
if (batch !== null)
|
|
684
|
+
return Number(batch);
|
|
685
|
+
const card = state.prepare("SELECT part FROM chat_progress WHERE binding=? AND run=?").get(binding.id, notification.run);
|
|
686
|
+
return card === undefined ? null : Number(card.part);
|
|
687
|
+
})();
|
|
688
|
+
const image = (artifact, sha256) => ({ taskId: where.task, run: where.run, artifact, sha256 });
|
|
689
|
+
const parts = plan.kind === "line" ? [{ text: plan.text, ...where }]
|
|
690
|
+
// Teams has no file upload here: one message that links to the saved result.
|
|
691
|
+
: state.channel === "teams" ? [{ text: plan.shots.length === 1 ? plan.shots[0].caption : `${plan.shots[0].caption} · ${plan.shots.length} screenshots`,
|
|
692
|
+
image: image(plan.shots[0].artifact, plan.shots[0].sha256), shot: { follows: null }, ...where }]
|
|
693
|
+
: plan.shots.map(one => ({ text: one.caption, image: image(one.artifact, one.sha256), shot: { follows }, ...where }));
|
|
694
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
695
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
696
|
+
state.plan(id, parts, now);
|
|
697
|
+
}
|
|
671
698
|
/** One progress card per exact result; separate urgent facts retain their own review link. */
|
|
672
699
|
export async function planChatNotifications(options) {
|
|
673
700
|
const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
|
|
@@ -710,6 +737,10 @@ export async function planChatNotifications(options) {
|
|
|
710
737
|
// Only when I'm needed (the default): the task's one card is edited in place, and a new message
|
|
711
738
|
// follows only when this person is needed. Every step keeps the branches below unchanged.
|
|
712
739
|
const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
|
|
740
|
+
if (notification.kind === RESULT_SHOTS_KIND) {
|
|
741
|
+
planResultShots(options, binding, notification, repos, id, now);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
713
744
|
if (notification.kind === LEAD_SAY_KIND) {
|
|
714
745
|
// The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
|
|
715
746
|
const content = { text: leadSayText(store, notification, binding.approver),
|
package/dist/chat-quiet.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { type ProgressEntity } from "./telegram-progress.js";
|
|
2
2
|
import { type PhoneTaskLink } from "./telegram-status.js";
|
|
3
3
|
import { type ChatBatch, type Notification, type Store } from "./store.js";
|
|
4
|
-
|
|
4
|
+
import { type DecideTarget } from "./chat-decide.js";
|
|
5
|
+
/** `also`: more buttons after `link`, such as [Look first] beside [Merge]. `target`: the one card a chat may act on in
|
|
6
|
+
* place (chat-decide.ts), when the message is about one result, plan, failure or pull request. */
|
|
5
7
|
export type QuietView = {
|
|
6
8
|
text: string;
|
|
7
9
|
entities: ProgressEntity[];
|
|
8
10
|
link: PhoneTaskLink;
|
|
9
11
|
also?: PhoneTaskLink[];
|
|
12
|
+
target?: DecideTarget;
|
|
10
13
|
};
|
|
11
14
|
export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
|
|
12
15
|
/** A fact about one placed task: in quiet mode it lands on that task's card. */
|