toolroll 0.9.53 → 0.9.54

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/chat-ask.js +8 -8
  3. package/dist/chat-core.d.ts +171 -0
  4. package/dist/chat-core.js +288 -0
  5. package/dist/chat-decide.d.ts +10 -8
  6. package/dist/chat-decide.js +26 -21
  7. package/dist/chat-delivery-state.d.ts +51 -75
  8. package/dist/chat-delivery-state.js +140 -170
  9. package/dist/chat-delivery.d.ts +21 -2
  10. package/dist/chat-delivery.js +313 -194
  11. package/dist/chat-flow.d.ts +29 -1
  12. package/dist/chat-flow.js +24 -50
  13. package/dist/chat-messages.d.ts +319 -0
  14. package/dist/chat-messages.js +841 -0
  15. package/dist/chat-migration.d.ts +30 -0
  16. package/dist/chat-migration.js +802 -0
  17. package/dist/chat-quiet.d.ts +3 -0
  18. package/dist/chat-quiet.js +5 -0
  19. package/dist/contracts/chat-content.d.ts +71 -2
  20. package/dist/contracts/chat-content.js +22 -3
  21. package/dist/contracts/chat-tables.d.ts +11 -4
  22. package/dist/contracts/chat-tables.js +165 -87
  23. package/dist/contracts/discord-callback.js +2 -1
  24. package/dist/contracts/slack-callback.js +3 -2
  25. package/dist/contracts/store-json.d.ts +1 -5
  26. package/dist/contracts/store-json.js +1 -4
  27. package/dist/contracts/teams-callback.js +2 -1
  28. package/dist/discord-chat.js +19 -38
  29. package/dist/discord-settings.js +4 -13
  30. package/dist/discord.js +4 -14
  31. package/dist/export.js +4 -4
  32. package/dist/mate-tools.js +3 -2
  33. package/dist/modes.d.ts +13 -1
  34. package/dist/modes.js +17 -1
  35. package/dist/notification-rows.d.ts +30 -0
  36. package/dist/notification-rows.js +102 -0
  37. package/dist/operate.js +5 -2
  38. package/dist/plane-review.js +5 -11
  39. package/dist/project-delete.js +21 -26
  40. package/dist/result-shots.js +2 -2
  41. package/dist/retention.js +1 -1
  42. package/dist/server/settings.js +5 -4
  43. package/dist/slack-api.d.ts +5 -1
  44. package/dist/slack-api.js +2 -2
  45. package/dist/slack-chat.d.ts +9 -4
  46. package/dist/slack-chat.js +29 -52
  47. package/dist/slack-settings.js +6 -15
  48. package/dist/slack.js +6 -16
  49. package/dist/store.d.ts +97 -364
  50. package/dist/store.js +207 -1531
  51. package/dist/teammate-question.js +12 -12
  52. package/dist/teams-chat.js +12 -9
  53. package/dist/teams-settings.js +2 -2
  54. package/dist/teams.js +4 -5
  55. package/dist/telegram-flow.js +5 -5
  56. package/dist/telegram.js +4 -4
  57. package/dist/toolroll-update.d.ts +10 -0
  58. package/dist/toolroll-update.js +50 -3
  59. package/dist/workspace-revision.js +3 -1
  60. package/package.json +1 -1
  61. package/dist/slack-state.d.ts +0 -13
  62. package/dist/slack-state.js +0 -10
@@ -30,8 +30,8 @@ const who = (store, question) => { const mate = store.getTeammate(question.teamm
30
30
  /** Everyone else's buttons retire once a question is answered anywhere. */
31
31
  function retireEverywhere(store, question, now, state) {
32
32
  store.retireTelegramQuestion(question, now);
33
- state?.prepare("UPDATE chat_question_action SET consumed=? WHERE question=? AND consumed IS NULL").run(now.toISOString(), question);
34
- state?.prepare("UPDATE chat_question_prompt SET consumed=? WHERE question=? AND consumed IS NULL").run(now.toISOString(), question);
33
+ state?.prepare("UPDATE chat_question_action SET consumed=? WHERE provider=:provider AND question=? AND consumed IS NULL").run(now.toISOString(), question);
34
+ state?.prepare("UPDATE chat_question_prompt SET consumed=? WHERE provider=:provider AND question=? AND consumed IS NULL").run(now.toISOString(), question);
35
35
  }
36
36
  // ---- Telegram ----------------------------------------------------------------------
37
37
  /** Buttons for the question, minted before the send (for the person it asks only); `place` stamps the message they land on. */
@@ -91,23 +91,23 @@ export function questionParts(store, notification, binding) {
91
91
  }
92
92
  /** The live buttons on one part, in the order they were minted. */
93
93
  export function chatQuestionButtons(state, part, now) {
94
- const row = state.prepare("SELECT payload FROM chat_part WHERE id=?").get(part);
94
+ const row = state.prepare("SELECT payload FROM chat_part WHERE provider=:provider AND id=?").get(part);
95
95
  const content = row === undefined ? null : partContent(String(row["payload"]));
96
96
  const labels = new Map((content?.question?.choices ?? []).map(one => [one.choice ?? "", one.label]));
97
- return state.prepare("SELECT token,choice FROM chat_question_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
97
+ return state.prepare("SELECT token,choice FROM chat_question_action WHERE provider=:provider AND part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
98
98
  .map(one => ({ token: String(one.token), label: (labels.get(one.choice ?? "") ?? IN_WORDS).slice(0, 75), words: one.choice === null }));
99
99
  }
100
100
  /** Show the tapped notice again with the answer, and without its buttons. */
101
101
  function repaint(state, part, event, line) {
102
- const row = state.prepare("SELECT payload FROM chat_part WHERE id=?").get(part);
102
+ const row = state.prepare("SELECT payload FROM chat_part WHERE provider=:provider AND id=?").get(part);
103
103
  const before = row === undefined ? { text: "" } : partContent(String(row["payload"]));
104
104
  const content = { text: `${before.text}\n\n${line}`.slice(0, 3400), edit: event.ts, ...(before.link === undefined ? {} : { link: before.link }), ...(before.channel === undefined ? {} : { channel: before.channel }) };
105
- state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?").run(savedChatPart(content), part);
105
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?").run(savedChatPart(content), part);
106
106
  }
107
107
  /** A tapped question button, inside the action's transaction; false when the token isn't one. */
108
108
  export function applyChatQuestionTap(options, event, binding, token, now) {
109
109
  const { store, state } = options;
110
- const action = state.prepare("SELECT a.*,p.message,e.binding AS owner,e.channel FROM chat_question_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE a.token=?").get(token);
110
+ const action = state.prepare("SELECT a.token,a.part,a.question,a.choice,a.expires,a.consumed,p.message,e.binding AS owner,e.channel FROM chat_question_action a JOIN chat_part p ON p.provider=a.provider AND p.id=a.part JOIN chat_event e ON e.provider=p.provider AND e.id=p.event WHERE a.provider=:provider AND a.token=?").get(token);
111
111
  if (action === undefined)
112
112
  return false;
113
113
  const say = (text) => state.plan(event.id, [{ text }], now);
@@ -126,9 +126,9 @@ export function applyChatQuestionTap(options, event, binding, token, now) {
126
126
  }
127
127
  if (action["choice"] === null) {
128
128
  // One open prompt per person, across flow decisions and questions: the newest is the one the next message answers.
129
- state.prepare("UPDATE chat_question_prompt SET consumed=? WHERE binding=? AND consumed IS NULL").run(now.toISOString(), binding.id);
130
- state.prepare("UPDATE chat_flow_prompt SET consumed=? WHERE binding=? AND consumed IS NULL").run(now.toISOString(), binding.id);
131
- state.prepare("INSERT INTO chat_question_prompt(binding,question,created,expires) VALUES(?,?,?,?)").run(binding.id, questionId, now.toISOString(), new Date(now.getTime() + PROMPT_MS).toISOString());
129
+ state.prepare("UPDATE chat_question_prompt SET consumed=? WHERE provider=:provider AND binding=? AND consumed IS NULL").run(now.toISOString(), binding.id);
130
+ state.prepare("UPDATE chat_flow_prompt SET consumed=? WHERE provider=:provider AND binding=? AND consumed IS NULL").run(now.toISOString(), binding.id);
131
+ state.prepare("INSERT INTO chat_question_prompt(provider,id,binding,question,created,expires) VALUES(:provider,(SELECT COALESCE(MAX(id),0)+1 FROM chat_question_prompt WHERE provider=:provider),?,?,?,?)").run(binding.id, questionId, now.toISOString(), new Date(now.getTime() + PROMPT_MS).toISOString());
132
132
  say(`Your next message here is your answer to ${who(store, question)}: “${question.question}” Send “cancel” to leave it.`);
133
133
  return true;
134
134
  }
@@ -149,14 +149,14 @@ export function answerChatQuestionPrompt(options, event, binding, input, now) {
149
149
  if (event.channel !== binding.channel)
150
150
  return false;
151
151
  return store.transact(() => {
152
- const prompt = state.prepare("SELECT * FROM chat_question_prompt WHERE binding=? AND consumed IS NULL AND expires>? ORDER BY id DESC LIMIT 1").get(binding.id, now.toISOString());
152
+ const prompt = state.prepare("SELECT * FROM chat_question_prompt WHERE provider=:provider AND binding=? AND consumed IS NULL AND expires>? ORDER BY id DESC LIMIT 1").get(binding.id, now.toISOString());
153
153
  if (prompt === undefined)
154
154
  return false;
155
155
  const id = Number(prompt["id"]), questionId = Number(prompt["question"]);
156
156
  const say = (text) => state.plan(event.id, [{ text }], now);
157
157
  const said = input.text.trim();
158
158
  if (/^cancel\.?$/i.test(said)) {
159
- state.prepare("UPDATE chat_question_prompt SET consumed=? WHERE id=?").run(now.toISOString(), id);
159
+ state.prepare("UPDATE chat_question_prompt SET consumed=? WHERE provider=:provider AND id=?").run(now.toISOString(), id);
160
160
  say("Left it unanswered. The buttons on the message still work.");
161
161
  return true;
162
162
  }
@@ -167,12 +167,12 @@ export async function deliverTeamsPart(options) {
167
167
  const shared = teamsDelivery(options), state = shared.state, { store, identity } = options, now = options.clock?.() ?? new Date();
168
168
  if (!options.current() || !state.owns(identity.installation, options.owner, now))
169
169
  return false;
170
- const row = state.prepare("SELECT p.* FROM chat_part p JOIN chat_event e ON e.id=p.event WHERE e.installation=? AND p.state='pending' AND (e.kind!='notice' OR ?=1) AND (p.next_at IS NULL OR p.next_at<=?) ORDER BY p.created,p.id LIMIT 1").get(identity.installation, options.canNotify?.() === false ? 0 : 1, now.toISOString());
170
+ const row = state.nextPart(identity.installation, options.canNotify?.() !== false, now, "created");
171
171
  if (!row)
172
172
  return false;
173
173
  const event = state.event(row.event), binding = event.binding === null ? null : state.bindingById(event.binding);
174
174
  if (!binding || new Date(row.created).getTime() + 86_400_000 < now.getTime()) {
175
- state.prepare("UPDATE chat_part SET state='dropped',problem='Delivery expired or access changed; open the saved chat' WHERE id=?").run(row.id);
175
+ state.dropPart(row.id, "Delivery expired or access changed; open the saved chat");
176
176
  return true;
177
177
  }
178
178
  try {
@@ -189,7 +189,7 @@ export async function deliverTeamsPart(options) {
189
189
  throw new TeamsError("Connected projects changed");
190
190
  let text = content.text, actions = [];
191
191
  if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
192
- state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
192
+ state.dropPart(row.id, "Removed by retention");
193
193
  return true;
194
194
  }
195
195
  if (content.image) {
@@ -206,7 +206,7 @@ export async function deliverTeamsPart(options) {
206
206
  const preview = proposalPreview(store, proposal, repos, "teams");
207
207
  text = content.phase === "armed" ? armedCardText(proposal, preview.text) : preview.text.replace("\n\nConfirm or Dismiss below. Nothing changes until you confirm.", "");
208
208
  if (preview.buttons && preview.text.length <= PLATFORM_LIMITS.teams) {
209
- actions = state.prepare("SELECT token,phase FROM chat_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(row.id, now.toISOString())
209
+ actions = state.partTokens(row.id, now)
210
210
  .map(action => ({ type: "Action.Submit", title: action.phase === "yes" ? armedYesLabel(proposal) : action.phase === "cancel" ? "Cancel" : action.phase === "dismiss" ? "Dismiss" : "Confirm", data: { so: String(action.token) } }));
211
211
  if (!actions.length)
212
212
  text = "This confirmation expired. Ask for a fresh proposal.";
@@ -224,7 +224,11 @@ export async function deliverTeamsPart(options) {
224
224
  const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
225
225
  // The lead's question to its owner: its options, then "Something else".
226
226
  const owner = content.ask ? chatAskButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
227
- actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
227
+ // A result's, plan's, failure's or pull request's own buttons (chat-decide.ts): its acts, then its links.
228
+ const decided = (content.decide?.rows.flat() ?? []).flatMap((one, index) => "token" in one
229
+ ? [{ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token }, ...(index === 0 ? { style: "positive" } : {}) }]
230
+ : openUrlAction(options.origin(), one.link));
231
+ actions = [...flow, ...asked, ...owner, ...decided, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
228
232
  }
229
233
  const target = content.edit ?? row.message;
230
234
  // The lead's own reply goes out in Teams' Markdown (bold anchors, labelled links); everything else stays plain.
@@ -238,17 +242,16 @@ export async function deliverTeamsPart(options) {
238
242
  const messageId = target ?? (typeof answer.id === "string" ? answer.id : null);
239
243
  if (messageId === null)
240
244
  throw new TeamsError("Teams did not confirm the message", 15_000, true);
241
- state.prepare("UPDATE chat_part SET state='sent',message=?,attempts=attempts+1,next_at=NULL,problem=NULL WHERE id=?").run(messageId, row.id);
245
+ state.partSent(row.id, messageId, now);
242
246
  return true;
243
247
  }
244
248
  catch (error) {
245
249
  if (error instanceof ChatDeliveryError && error.permanent) {
246
- state.prepare("UPDATE chat_part SET state='dropped',next_at=NULL,problem=? WHERE id=?").run(error.message, row.id);
250
+ state.dropPart(row.id, error.message);
247
251
  return true;
248
252
  }
249
253
  const problem = error instanceof TeamsError ? error : new TeamsError("Teams delivery failed", 15_000, true);
250
- state.prepare("UPDATE chat_part SET attempts=attempts+1,uncertain=?,next_at=?,problem=?,state=CASE WHEN attempts>=20 THEN 'dropped' ELSE state END WHERE id=?")
251
- .run(problem.uncertain ? 1 : 0, new Date(now.getTime() + problem.retryMs).toISOString(), problem.message, row.id);
254
+ state.partFailed(row.id, { problem: problem.message, until: new Date(now.getTime() + problem.retryMs).toISOString(), uncertain: problem.uncertain }, 20);
252
255
  return true;
253
256
  }
254
257
  }
@@ -8,9 +8,9 @@ export function teamsSettingsHtml(store, dir, csrf, options = {}) {
8
8
  const bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [];
9
9
  const binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null;
10
10
  const others = bindings.length - (binding === null ? 0 : 1);
11
- const runtime = credentials ? state.db.prepare("SELECT connected,problem,lease_until FROM teams_runtime WHERE installation=?").get(credentials.installation) : null;
11
+ const runtime = credentials ? state.runtime(credentials.installation) : null;
12
12
  const now = options.now ?? new Date();
13
- const live = runtime && typeof runtime.lease_until === "string" && runtime.lease_until > now.toISOString() && runtime.connected;
13
+ const live = runtime && runtime.leaseUntil !== null && runtime.leaseUntil > now.toISOString() && runtime.connected;
14
14
  const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">`;
15
15
  const password = '<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>';
16
16
  const post = (action, content) => `<form method="post" action="/settings/teams/${action}" class="card">${hidden}${content}</form>`;
package/dist/teams.js CHANGED
@@ -93,7 +93,7 @@ export async function followTeams(options) {
93
93
  const current = loadTeamsCredentials(options.dir);
94
94
  return alive && !options.signal.aborted && current?.installation === credentials.installation && current.secret === credentials.secret && state.owns(credentials.installation, owner);
95
95
  };
96
- const problem = (message) => state.prepare("UPDATE chat_runtime SET problem=? WHERE installation=? AND owner=?").run(message, credentials.installation, owner);
96
+ const problem = (message) => state.setProblem(credentials.installation, owner, message);
97
97
  const stop = () => { alive = false; };
98
98
  options.signal.addEventListener("abort", stop, { once: true });
99
99
  const heartbeat = setInterval(() => { if (!same() || !state.lease(credentials.installation, owner, new Date()))
@@ -103,7 +103,7 @@ export async function followTeams(options) {
103
103
  // Connected means the app can sign in; inbound reachability is the operator's endpoint to verify.
104
104
  try {
105
105
  await teamsAccessToken(credentials, options.fetcher);
106
- state.prepare("UPDATE chat_runtime SET connected=?,problem=NULL WHERE installation=? AND owner=?").run(new Date().toISOString(), credentials.installation, owner);
106
+ state.setConnected(credentials.installation, owner, new Date());
107
107
  }
108
108
  catch (error) {
109
109
  problem(error instanceof TeamsError ? error.message : "Microsoft sign-in failed. Check the Teams app credentials.");
@@ -111,8 +111,7 @@ export async function followTeams(options) {
111
111
  continue;
112
112
  }
113
113
  while (same()) {
114
- const retry = state.prepare("SELECT retry_at FROM chat_runtime WHERE installation=?").get(credentials.installation)?.retry_at;
115
- if (typeof retry === "string" && retry > new Date().toISOString()) {
114
+ if (state.retryAt(credentials.installation) > new Date().toISOString()) {
116
115
  await pause(1000);
117
116
  continue;
118
117
  }
@@ -134,7 +133,7 @@ export async function followTeams(options) {
134
133
  clearInterval(heartbeat);
135
134
  alive = false;
136
135
  options.signal.removeEventListener("abort", stop);
137
- state.prepare("UPDATE chat_runtime SET owner=NULL,lease_until=NULL,connected=NULL WHERE installation=? AND owner=?").run(credentials.installation, owner);
136
+ state.stop(credentials.installation, owner);
138
137
  }
139
138
  await pause(5000);
140
139
  }
@@ -180,7 +180,7 @@ export function applyFlowTap(store, binding, action, message, repos, now) {
180
180
  store.retireTelegramFlowVisit(action.card, action.entry, now);
181
181
  const mint = (phase) => {
182
182
  const token = randomBytes(16).toString("hex");
183
- store.handle.prepare("INSERT INTO telegram_flow_confirm (token, binding, chat_id, message_id, card, entry, phase, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?)")
183
+ store.telegramChat().prepare("INSERT INTO chat_flow_action (provider, token, binding, chat, message, card, entry, action, created, expires) VALUES (:provider,?,?,?,?,?,?,?,?,?)")
184
184
  .run(token, binding.id, binding.chatId, message.messageId, action.card, action.entry, phase, now.toISOString(), new Date(now.getTime() + CONFIRM_TTL_MS).toISOString());
185
185
  return token;
186
186
  };
@@ -201,17 +201,17 @@ export function applyFlowTap(store, binding, action, message, repos, now) {
201
201
  export function flowConfirmOf(store, token) {
202
202
  if (!/^[0-9a-f]{32}$/.test(token))
203
203
  return null;
204
- const row = store.handle.prepare("SELECT * FROM telegram_flow_confirm WHERE token = ?").get(token);
204
+ const row = store.telegramChat().prepare("SELECT * FROM chat_flow_action WHERE provider = :provider AND token = ? AND action IN ('yes','cancel')").get(token);
205
205
  if (row === undefined)
206
206
  return null;
207
- return { token: String(row["token"]), binding: Number(row["binding"]), chatId: String(row["chat_id"]), messageId: String(row["message_id"]), card: Number(row["card"]), entry: Number(row["entry"]),
208
- phase: String(row["phase"]) === "cancel" ? "cancel" : "yes", expiresAt: String(row["expires_at"]), consumedAt: row["consumed_at"] == null ? null : String(row["consumed_at"]) };
207
+ return { token: String(row["token"]), binding: Number(row["binding"]), chatId: String(row["chat"]), messageId: String(row["message"]), card: Number(row["card"]), entry: Number(row["entry"]),
208
+ phase: String(row["action"]) === "cancel" ? "cancel" : "yes", expiresAt: String(row["expires"]), consumedAt: row["consumed"] == null ? null : String(row["consumed"]) };
209
209
  }
210
210
  /** The second tap, inside the update's transaction: Yes decides the card through the console's door, once; Cancel puts
211
211
  * the card's own buttons back. The caller has proved the binding, chat and message are the ones it was armed on. */
212
212
  export function applyFlowConfirm(store, binding, confirm, message, repos, now) {
213
213
  const body = message.text.replace(FLOW_QUESTION, "");
214
- const spend = () => store.handle.prepare("UPDATE telegram_flow_confirm SET consumed_at = ? WHERE card = ? AND entry = ? AND message_id = ? AND consumed_at IS NULL").run(now.toISOString(), confirm.card, confirm.entry, confirm.messageId);
214
+ const spend = () => store.telegramChat().prepare("UPDATE chat_flow_action SET consumed = ? WHERE provider = :provider AND action IN ('yes','cancel') AND card = ? AND entry = ? AND message = ? AND consumed IS NULL").run(now.toISOString(), confirm.card, confirm.entry, confirm.messageId);
215
215
  if (confirm.consumedAt !== null || confirm.expiresAt <= now.toISOString()) {
216
216
  spend();
217
217
  const now_ = flowWhereNow(store, confirm.card, repos);
package/dist/telegram.js CHANGED
@@ -634,7 +634,7 @@ function decideOfferFor(store, binding, target, projects, now, root) {
634
634
  if (target === null || target === undefined)
635
635
  return null;
636
636
  const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
637
- return principal.ok ? decideOffer(store, target, principal.who, now, root, "telegram") : null;
637
+ return principal.ok ? decideOffer(store, target, principal.who, now, "telegram", root) : null;
638
638
  }
639
639
  /** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
640
640
  async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
@@ -2518,9 +2518,9 @@ function noteDigestOf(note) {
2518
2518
  * machine — "never silently answer without the expected note").
2519
2519
  */
2520
2520
  function expiredDraftGuard(store, binding, decisionId, now) {
2521
- const expired = store.handle
2522
- .prepare(`SELECT id FROM telegram_note_draft
2523
- WHERE binding = ? AND decision = ? AND state IN ('pending','armed') AND expires_at <= ?`)
2521
+ const expired = store.telegramChat()
2522
+ .prepare(`SELECT id FROM chat_note_draft
2523
+ WHERE provider = :provider AND binding = ? AND decision = ? AND state IN ('pending','armed') AND expires <= ?`)
2524
2524
  .get(binding.id, decisionId, now.toISOString());
2525
2525
  if (expired === undefined)
2526
2526
  return false;
@@ -103,6 +103,12 @@ export declare function releaseNotes(changelog: string, version: string): string
103
103
  * by its directory), and the directory after, so a crash never leaves a torn
104
104
  * database, catalog or command. */
105
105
  export declare function durableRename(temp: string, target: string): void;
106
+ type Moved = {
107
+ into: string;
108
+ where: string;
109
+ rows?: string;
110
+ };
111
+ export declare const CHAT_MOVES: Record<string, Moved>;
106
112
  /** The tables a migration may remove whole (v115's removed features). */
107
113
  export declare const RETIRED_TABLES: readonly string[];
108
114
  /** A saved routine, as its move into a scheduled flow must account for it. */
@@ -127,6 +133,10 @@ export type TableDigest = {
127
133
  nonNull: Record<string, number>;
128
134
  };
129
135
  routines?: RoutineDigest[];
136
+ moved?: {
137
+ rows: number;
138
+ found: number;
139
+ };
130
140
  };
131
141
  /** Each saved table's columns, row count and last rowid; the append-only ones hashed (deploy-browser's rehearsal check).
132
142
  * Bounded: only the ledger and small tables are read row by row. */
@@ -324,9 +324,46 @@ export function durableRename(temp, target) {
324
324
  * `receives`: the only rows the table may gain are those receipts. `compacted`: rows may go only into summary rows,
325
325
  * whose `sum` grows by exactly as many. `retired`: a removed feature's table, which may go whole. `fromRoutines`: rows
326
326
  * kept as they were, and the only rows the table may gain are one per saved routine, moved into a scheduled flow (its
327
- * flow, or its schedule trigger naming that routine) — on only when the routine was approved and running. */
327
+ * flow, or its schedule trigger naming that routine) — on only when the routine was approved and running. `moved`:
328
+ * the table may go only when every row it had (or the ones `rows` counts) arrived in `into`: the rows there that
329
+ * `where` picks grew by exactly that many. */
328
330
  const LEGACY_DESTINATION = "legacy:single-destination";
331
+ /** v116: every old per-app chat table into the shared chat tables, keyed by provider (chat-migration.ts). Several old
332
+ * tables fan into one shared table; each picks out only its own rows there, so every count is checked on its own. */
333
+ const TELEGRAM_MOVES = {
334
+ telegram_binding: { into: "chat_binding", where: "1" },
335
+ telegram_pairing: { into: "chat_pair", where: "1" },
336
+ telegram_team_chat: { into: "chat_room", where: "1" },
337
+ // An applied update is a receipt; a pushed one still waiting is queued (one already applied was spent with it).
338
+ telegram_update: { into: "chat_event", where: "kind = 'update' AND state = 'done'" },
339
+ telegram_inbox: { into: "chat_event", where: "kind = 'update' AND state = 'queued'", rows: "update_id NOT IN (SELECT update_id FROM telegram_update)" },
340
+ telegram_conversation: { into: "chat_event", where: "kind = 'message'" },
341
+ telegram_conversation_part: { into: "chat_part", where: "1" },
342
+ telegram_proposal_action: { into: "chat_action", where: "proposal IS NOT NULL" },
343
+ telegram_action: { into: "chat_action", where: "decision IS NOT NULL" },
344
+ telegram_flow_action: { into: "chat_flow_action", where: "action IN ('approve','edit','send-back')" },
345
+ telegram_flow_confirm: { into: "chat_flow_action", where: "action IN ('yes','cancel')" },
346
+ telegram_flow_choice: { into: "chat_flow_choice", where: "1" },
347
+ telegram_flow_prompt: { into: "chat_flow_prompt", where: "1" },
348
+ telegram_question_action: { into: "chat_question_action", where: "1" },
349
+ telegram_question_prompt: { into: "chat_question_prompt", where: "1" },
350
+ telegram_decision_message: { into: "chat_message_ref", where: "kind = 'decision'" },
351
+ telegram_task_message: { into: "chat_message_ref", where: "kind = 'task'" },
352
+ telegram_outbound_message: { into: "chat_message_ref", where: "kind = 'notification'" },
353
+ telegram_note_draft: { into: "chat_note_draft", where: "1" },
354
+ // A bot's lease and its rate-limit wait share its one runtime row.
355
+ bridge_lease: { into: "chat_runtime", where: "lease_until IS NOT NULL" },
356
+ telegram_retry: { into: "chat_runtime", where: "retry_at IS NOT NULL" },
357
+ telegram_digest: { into: "chat_digest", where: "1", rows: "id = 1" },
358
+ };
359
+ const APP_TABLES = ["binding", "pair", "event", "part", "action", "progress", "runtime", "room", "meta",
360
+ "flow_action", "flow_prompt", "flow_choice", "flow_note", "question_action", "question_prompt", "ask_action"];
361
+ export const CHAT_MOVES = {
362
+ ...Object.fromEntries(Object.entries(TELEGRAM_MOVES).map(([table, move]) => [table, { ...move, where: `provider = 'telegram' AND (${move.where})` }])),
363
+ ...Object.fromEntries(["slack", "discord", "teams"].flatMap(app => APP_TABLES.map(suffix => [`${app}_${suffix}`, { into: `chat_${suffix}`, where: `provider = '${app}'` }]))),
364
+ };
329
365
  const HISTORY_RULES = {
366
+ ...Object.fromEntries(Object.entries(CHAT_MOVES).map(([table, moved]) => [table, { moved }])),
330
367
  action_ledger: { appendOnly: true }, ledger_seal: { appendOnly: true }, ledger_checkpoint: { appendOnly: true },
331
368
  // v114: a finished run whose process witnesses have all exited keeps one summary row in place of them.
332
369
  run_process: { compacted: { into: "run_process_summary", sum: "witnesses" } }, run_process_summary: { appendOnly: true },
@@ -363,6 +400,8 @@ function receipts(db, name, destination, columns) {
363
400
  const r = db.prepare("SELECT count(*) AS n" + columns.map(column => ",count(" + quote(column) + ") AS " + quote(column)).join("") + " FROM " + quote(name) + " WHERE destination = ?").get(destination);
364
401
  return { count: Number(r["n"]), nonNull: Object.fromEntries(columns.map(column => [column, Number(r[column])])) };
365
402
  }
403
+ /** The rows a moved table's rule picks out in its destination (none while there is no such table). */
404
+ const arrived = (db, move) => db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(move.into) ? Number(db.prepare("SELECT count(*) AS n FROM " + quote(move.into) + " WHERE " + move.where).get()["n"]) : 0;
366
405
  const summed = (db, into) => db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(into.into) ? Number(db.prepare("SELECT coalesce(sum(" + quote(into.sum) + "),0) AS n FROM " + quote(into.into)).get()["n"]) : 0;
367
406
  /** Each saved table's columns, row count and last rowid; the append-only ones hashed (deploy-browser's rehearsal check).
368
407
  * Bounded: only the ledger and small tables are read row by row. */
@@ -388,6 +427,9 @@ export function historySnapshot(db) {
388
427
  t.summed = summed(db, rule.compacted);
389
428
  if (rule?.fromRoutines)
390
429
  t.routines = savedRoutines(db);
430
+ if (rule?.moved)
431
+ t.moved = { found: arrived(db, rule.moved),
432
+ rows: rule.moved.rows === undefined ? t.count : Number(db.prepare("SELECT count(*) AS n FROM " + quote(name) + " WHERE " + rule.moved.rows).get()["n"]) };
391
433
  return t;
392
434
  });
393
435
  }
@@ -450,8 +492,13 @@ export function changedHistory(db, before) {
450
492
  return got !== undefined && want !== undefined && got.count === want.count && Object.entries(want.nonNull).every(([c, n]) => got.nonNull[c] === n) && t.carry === want.count;
451
493
  };
452
494
  return before.filter(t => {
453
- if (!tables.has(t.name))
454
- return HISTORY_RULES[t.name]?.retired !== true;
495
+ if (!tables.has(t.name)) {
496
+ const rule = HISTORY_RULES[t.name];
497
+ if (rule?.retired === true)
498
+ return false;
499
+ const move = rule?.moved;
500
+ return move === undefined || t.moved === undefined || arrived(db, move) - t.moved.found !== t.moved.rows;
501
+ }
455
502
  const rule = HISTORY_RULES[t.name], present = new Set(tableColumns(db, t.name));
456
503
  if (t.columns.some(c => !present.has(c) && (!rule?.dropped?.includes(c) || (t.nonNull?.[c] !== 0 && !accounted(rule, t)))))
457
504
  return true;
@@ -4,10 +4,12 @@ export const WORKSPACE_REVISION_KEY = 'workspace-content:v1';
4
4
  const KEY = WORKSPACE_REVISION_KEY;
5
5
  const MAX_AGE_MS = 60_000;
6
6
  const OMIT = new Set(['schema_version', 'service_cursor', 'wake', 'notification_delivery', 'sqlite_sequence',
7
- 'team_read', 'team_request', 'push_delivery', 'telegram_update', 'telegram_outbound_message', 'telegram_retry', 'bridge_lease']);
7
+ 'team_read', 'team_request', 'push_delivery', 'chat_message_ref']);
8
8
  const QUIET = {
9
9
  runner: ['heartbeat_at'], claim: ['heartbeat_at', 'expires_at'], watch_lease: ['heartbeat_at', 'expires_at'],
10
10
  watch_episode: ['ticks'], provider_readiness: ['observed_at'],
11
+ // A chat worker's lease, poll cursor and rate-limit wait are its own bookkeeping; its connection and problem are news.
12
+ chat_runtime: ['owner', 'lease_until', 'generation', 'cursor', 'heartbeat', 'retry_at'],
11
13
  };
12
14
  /** A quiet beat that revives what a view already rendered as gone: the rows a beat may move, read before and after.
13
15
  * A runner beat after its liveness window (or backwards) is a revival; a lease renewed at or past its expiry is one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.53",
3
+ "version": "0.9.54",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"
@@ -1,13 +0,0 @@
1
- /** Slack compatibility names for the shared durable transport state. */
2
- import { ChatState } from "./chat-delivery-state.js";
3
- import type { Store } from "./store.js";
4
- export { chatHash as slackHash } from "./chat-delivery-state.js";
5
- export type { ChatBinding as SlackBinding, ChatEvent as SlackEvent, ChatPart as SlackPart, ChatContent as SlackContent, } from "./chat-delivery-state.js";
6
- import type { ChatIdentity } from "./chat-delivery-state.js";
7
- export type SlackIdentity = ChatIdentity & {
8
- team: string;
9
- };
10
- export declare const SLACK_SCHEMA: string, SLACK_TABLES: string[];
11
- export declare class SlackState extends ChatState {
12
- constructor(store: Store);
13
- }
@@ -1,10 +0,0 @@
1
- /** Slack compatibility names for the shared durable transport state. */
2
- import { ChatState } from "./chat-delivery-state.js";
3
- import { chatSchema, chatTables } from "./contracts/chat-tables.js";
4
- export { chatHash as slackHash } from "./chat-delivery-state.js";
5
- export const SLACK_SCHEMA = chatSchema("slack"), SLACK_TABLES = chatTables("slack");
6
- export class SlackState extends ChatState {
7
- constructor(store) {
8
- super(store, "slack");
9
- }
10
- }