openmausbot 0.1.84 → 0.1.86
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/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
- package/dist/assets/arc-DW7mrI4p.js +1 -0
- package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
- package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
- package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
- package/dist/assets/channel-BXFkgus9.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
- package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
- package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
- package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
- package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
- package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
- package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
- package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
- package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
- package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
- package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
- package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
- package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
- package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
- package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
- package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
- package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
- package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
- package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
- package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
- package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
- package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
- package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
- package/dist/assets/graph-D8UmR1vK.js +1 -0
- package/dist/assets/index-BGgqlWPJ.css +1 -0
- package/dist/assets/index-BtHo8ZKl.js +577 -0
- package/dist/assets/{index-DNa2umw-.js → index-h9FFNwTu.js} +1 -1
- package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
- package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
- package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
- package/dist/assets/linear-3su2TG93.js +1 -0
- package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
- package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
- package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
- package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
- package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
- package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
- package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
- package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
- package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
- package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
- package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
- package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
- package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
- package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
- package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +1 -0
- package/dist-server/container-mcp.js +231 -17
- package/dist-server/drivers/agents-proxy.js +16097 -1411
- package/dist-server/enterprise/LICENSE +32 -0
- package/dist-server/enterprise/server/index.js +6 -5
- package/dist-server/hooks/omb-hook.js +74 -0
- package/dist-server/index.js +12495 -7584
- package/dist-server/local-computer-proxy.js +83 -1
- package/dist-server/local-computer.js +14 -2
- package/dist-server/mcp-server.js +3 -1
- package/dist-server/openmausbot.js +5458 -4587
- package/dist-server/pair-cli.js +5458 -4587
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/admin-activity.js +410 -0
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/bot-package.js +2 -0
- package/dist-server/server/bot-visibility.js +462 -0
- package/dist-server/server/box.js +25 -5
- package/dist-server/server/browser-engine.js +87 -10
- package/dist-server/server/browser-live.js +7 -5
- package/dist-server/server/browser-runtime.js +66 -11
- package/dist-server/server/channel-queue.js +2 -0
- package/dist-server/server/checkpoints.js +62 -18
- package/dist-server/server/cli-prompts.js +26 -2
- package/dist-server/server/cli.js +79 -10
- package/dist-server/server/commands.js +27 -0
- package/dist-server/server/compaction-summary.js +78 -0
- package/dist-server/server/composio.js +49 -11
- package/dist-server/server/config.js +137 -14
- package/dist-server/server/context-budget.js +29 -0
- package/dist-server/server/context-rebuild.js +72 -0
- package/dist-server/server/decision-log.js +209 -48
- package/dist-server/server/default-model-selection.js +20 -3
- package/dist-server/server/delegations.js +11 -9
- package/dist-server/server/digest.js +130 -0
- package/dist-server/server/drivers/acp/core.js +637 -238
- package/dist-server/server/drivers/acp/grok.js +4 -3
- package/dist-server/server/drivers/agents-call.js +1123 -0
- package/dist-server/server/drivers/agents-catalog.js +659 -0
- package/dist-server/server/drivers/agents-client.js +28 -0
- package/dist-server/server/drivers/agents-proxy.js +18 -1683
- package/dist-server/server/drivers/agents-result.js +23 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
- package/dist-server/server/drivers/claude.js +83 -16
- package/dist-server/server/drivers/codex-mcp-names.js +44 -0
- package/dist-server/server/drivers/codex.js +32 -4
- package/dist-server/server/drivers/grok.js +2 -1
- package/dist-server/server/drivers/openai-chat.js +66 -45
- package/dist-server/server/drivers/openai-compat.js +16 -8
- package/dist-server/server/enterprise.js +97 -16
- package/dist-server/server/external-runtime.js +83 -0
- package/dist-server/server/guarded-requests.js +60 -0
- package/dist-server/server/harness/http.js +61 -0
- package/dist-server/server/hooks/omb-hook.js +103 -0
- package/dist-server/server/hosted-models.js +151 -0
- package/dist-server/server/hosted-slack.js +11 -0
- package/dist-server/server/index.js +2810 -678
- package/dist-server/server/local-computer.js +21 -2
- package/dist-server/server/managed-desktop.js +79 -14
- package/dist-server/server/managed-policy.js +153 -0
- package/dist-server/server/mcp-bridge.js +10 -2
- package/dist-server/server/mcp-tool-images.js +37 -0
- package/dist-server/server/mcp-tool-schema.js +108 -0
- package/dist-server/server/message-db.js +108 -21
- package/dist-server/server/message-file.js +3 -2
- package/dist-server/server/model-context-window.js +7 -0
- package/dist-server/server/model-prices.js +121 -0
- package/dist-server/server/notify.js +10 -1
- package/dist-server/server/package-export.js +1 -0
- package/dist-server/server/peer-roster.js +17 -4
- package/dist-server/server/provider-icon.js +20 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/recent-work.js +15 -0
- package/dist-server/server/request-auth.js +97 -0
- package/dist-server/server/room-handoffs.js +41 -13
- package/dist-server/server/routes/hosted-slack.js +20 -0
- package/dist-server/server/routes/table.js +14 -0
- package/dist-server/server/routine-requests.js +39 -1
- package/dist-server/server/routines.js +65 -9
- package/dist-server/server/shared-computer-control.js +31 -4
- package/dist-server/server/spend.js +111 -16
- package/dist-server/server/steer-queue.js +12 -1
- package/dist-server/server/store.js +384 -33
- package/dist-server/server/system-prompt.js +4 -4
- package/dist-server/server/team-backup.js +19 -2
- package/dist-server/server/thread-starters.js +53 -0
- package/dist-server/server/tool-results.js +72 -0
- package/dist-server/server/tts/grok.js +74 -0
- package/dist-server/server/tts/index.js +19 -1
- package/dist-server/server/usage-ledger.js +31 -8
- package/dist-server/server/webhooks.js +28 -0
- package/dist-server/server/workspace-backup-policy.js +9 -2
- package/dist-server/server/workspace-backup.js +7 -4
- package/dist-server/shared/approval-mode.js +7 -1
- package/dist-server/shared/digest.js +1 -0
- package/dist-server/shared/markdown-windows-paths.js +36 -0
- package/dist-server/shared/mention-boundary.js +15 -0
- package/dist-server/shared/provider-icon.js +110 -0
- package/dist-server/shared/routines.js +7 -1
- package/dist-server/shared/team-backup.js +5 -0
- package/dist-server/src/lib/composer-attachments.js +16 -0
- package/dist-server/vps-container-mcp.js +236 -22
- package/enterprise/README.md +33 -12
- package/enterprise/server/index.js +6 -5
- package/package.json +1 -1
- package/dist/assets/index-BbU5REzd.js +0 -310
- package/dist/assets/index-CLGfYlx_.css +0 -1
|
@@ -78,8 +78,15 @@ export class RoomHandoffs {
|
|
|
78
78
|
trackExecutionPauses() {
|
|
79
79
|
const now = this.now();
|
|
80
80
|
const executing = new Set();
|
|
81
|
+
// Roots that still own unsettled nodes. A conversation stopped while a
|
|
82
|
+
// dispatched teammate executes leaves a terminal root above live work;
|
|
83
|
+
// pause accounting must survive that root until the whole tree settles.
|
|
84
|
+
const unsettled = new Set();
|
|
81
85
|
for (const n of this.nodes.values()) {
|
|
82
|
-
if (
|
|
86
|
+
if (terminal(n))
|
|
87
|
+
continue;
|
|
88
|
+
unsettled.add(n.rootId);
|
|
89
|
+
if (n.status === "running")
|
|
83
90
|
executing.add(n.rootId);
|
|
84
91
|
}
|
|
85
92
|
for (const rootId of executing) {
|
|
@@ -93,7 +100,7 @@ export class RoomHandoffs {
|
|
|
93
100
|
pause.since = undefined;
|
|
94
101
|
}
|
|
95
102
|
const root = this.nodes.get(rootId);
|
|
96
|
-
if (!root ||
|
|
103
|
+
if (!root || !unsettled.has(rootId))
|
|
97
104
|
this.pauses.delete(rootId);
|
|
98
105
|
}
|
|
99
106
|
}
|
|
@@ -236,12 +243,13 @@ export class RoomHandoffs {
|
|
|
236
243
|
const node = this.nodes.get(generation);
|
|
237
244
|
if (!node || node.status !== "source")
|
|
238
245
|
return;
|
|
246
|
+
// An accepted assignment belongs to the queue, not the provider that
|
|
247
|
+
// submitted it. A failed/expired source turn must not erase that work.
|
|
248
|
+
// Explicit Stop, deletion and revoked routes still cancel separately.
|
|
239
249
|
if (!ok)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
this.publish(node);
|
|
244
|
-
}
|
|
250
|
+
node.result = "The originating turn ended before its teammates returned.";
|
|
251
|
+
node.status = "waiting";
|
|
252
|
+
this.publish(node);
|
|
245
253
|
}
|
|
246
254
|
cancelTree(node, reason, status = "cancelled") {
|
|
247
255
|
for (const child of this.children(node.id))
|
|
@@ -252,6 +260,8 @@ export class RoomHandoffs {
|
|
|
252
260
|
node.result = reason;
|
|
253
261
|
this.controllers.get(node.id)?.abort();
|
|
254
262
|
}
|
|
263
|
+
// Settlement closes the paused span now, not at the next periodic tick.
|
|
264
|
+
this.trackExecutionPauses();
|
|
255
265
|
this.publish(node);
|
|
256
266
|
}
|
|
257
267
|
cancelRoom(groupId, threadId) {
|
|
@@ -300,6 +310,7 @@ export class RoomHandoffs {
|
|
|
300
310
|
node.status = "cancelled";
|
|
301
311
|
node.result = reason;
|
|
302
312
|
this.controllers.get(node.id)?.abort();
|
|
313
|
+
this.trackExecutionPauses();
|
|
303
314
|
this.publish(node);
|
|
304
315
|
}
|
|
305
316
|
return left;
|
|
@@ -337,8 +348,10 @@ export class RoomHandoffs {
|
|
|
337
348
|
}
|
|
338
349
|
if (n.status !== "queued" && n.status !== "resume")
|
|
339
350
|
continue;
|
|
340
|
-
//
|
|
341
|
-
|
|
351
|
+
// Independent conversations can start as soon as work is accepted.
|
|
352
|
+
// Same-room speakers still serialize; never overlap their shared chat.
|
|
353
|
+
if (parent && (parent.status === "source" || parent.status === "running") &&
|
|
354
|
+
(parent.threadId === n.threadId || (n.groupId && n.groupId === parent.groupId)))
|
|
342
355
|
continue;
|
|
343
356
|
// A stopped source stops waiting; only work that never started is
|
|
344
357
|
// dropped with it. A teammate mid-turn keeps its process and reports.
|
|
@@ -371,14 +384,29 @@ export class RoomHandoffs {
|
|
|
371
384
|
if (terminal(n))
|
|
372
385
|
return;
|
|
373
386
|
n.result = result.text.slice(0, 12_000);
|
|
374
|
-
if (
|
|
375
|
-
this.cancelTree(n, n.result || "Room agent failed", "failed");
|
|
376
|
-
else if (this.children(n.id).length > childCount)
|
|
387
|
+
if (this.children(n.id).length > childCount)
|
|
377
388
|
n.status = "waiting";
|
|
389
|
+
else if (!result.ok)
|
|
390
|
+
this.cancelTree(n, n.result || "Room agent failed", "failed");
|
|
378
391
|
else
|
|
379
392
|
n.status = "completed";
|
|
393
|
+
// Close the paused span with the settlement itself: work enqueued
|
|
394
|
+
// before the next periodic tick must be admitted against the aged
|
|
395
|
+
// budget, not the still-open pause's overstated runway.
|
|
396
|
+
this.trackExecutionPauses();
|
|
380
397
|
this.publish(n);
|
|
381
|
-
}).catch(e => {
|
|
398
|
+
}).catch(e => {
|
|
399
|
+
if (terminal(n))
|
|
400
|
+
return;
|
|
401
|
+
n.result = String(e).slice(0, 1000);
|
|
402
|
+
if (this.children(n.id).length > childCount) {
|
|
403
|
+
n.status = "waiting";
|
|
404
|
+
this.trackExecutionPauses();
|
|
405
|
+
this.publish(n);
|
|
406
|
+
}
|
|
407
|
+
else
|
|
408
|
+
this.cancelTree(n, n.result, "failed");
|
|
409
|
+
})
|
|
382
410
|
.finally(() => this.controllers.delete(n.id));
|
|
383
411
|
}
|
|
384
412
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// GET /api/bots/:id/slack-management: where, in the organisation's Admin, this
|
|
2
|
+
// bot's Slack app is managed. A deep link only: this server holds no Slack
|
|
3
|
+
// logic, contacts nobody, and the link carries identifiers, never a credential.
|
|
4
|
+
// Client-scoped in server/request-auth.ts, so a member reading Bot Settings
|
|
5
|
+
// gets the same link; Admin authorizes whoever opens it.
|
|
6
|
+
import { hostedSlackManagement } from "../hosted-slack.js";
|
|
7
|
+
import { PASS } from "./table.js";
|
|
8
|
+
export function createHostedSlackRoutes(deps) {
|
|
9
|
+
return async ({ res, path, method, json }) => {
|
|
10
|
+
const m = path.match(/^\/api\/bots\/([\w-]+)\/slack-management$/);
|
|
11
|
+
if (!m || method !== "GET")
|
|
12
|
+
return PASS;
|
|
13
|
+
const bot = deps.bot(m[1]);
|
|
14
|
+
if (!bot || bot.hidden)
|
|
15
|
+
return json(res, 404, { error: "no such bot" });
|
|
16
|
+
res.setHeader("cache-control", "no-store");
|
|
17
|
+
// The id in the link is the stored bot's, not the text from the request.
|
|
18
|
+
return json(res, 200, hostedSlackManagement(bot.id, deps.hostedReady(), deps.env));
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** "Not my route": the next handler, then index.ts's inline routes, get a turn. */
|
|
2
|
+
export const PASS = Symbol("route.pass");
|
|
3
|
+
export const ROUTES = [];
|
|
4
|
+
/** Runs handlers in order until one answers; true means stop routing. A
|
|
5
|
+
* handler that wrote a response but returned PASS by mistake still counts as
|
|
6
|
+
* answered, so the request can never reach a second handler. */
|
|
7
|
+
export async function dispatchRoutes(routes, ctx) {
|
|
8
|
+
for (const route of routes) {
|
|
9
|
+
const out = await route(ctx);
|
|
10
|
+
if (out !== PASS || ctx.res.headersSent || ctx.res.writableEnded)
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { cronScheduleLabel } from "../shared/cron-label.js";
|
|
4
5
|
import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
|
|
@@ -59,6 +60,7 @@ const routineToolDefinitionSchema = z.object({
|
|
|
59
60
|
durationMinutes: z.number().optional(),
|
|
60
61
|
timeoutMinutes: z.number().nullable().optional(),
|
|
61
62
|
continuity: z.boolean().optional(),
|
|
63
|
+
overlap: z.enum(["skip", "queue"]).optional(),
|
|
62
64
|
}).strict();
|
|
63
65
|
const routineToolChangesSchema = routineToolDefinitionSchema
|
|
64
66
|
.omit({ timeoutMinutes: true })
|
|
@@ -174,6 +176,7 @@ const storedDefinitionSchema = z.object({
|
|
|
174
176
|
durationMinutes: z.number().int().min(5).max(240),
|
|
175
177
|
timeoutMinutes: z.number().int().min(5).max(240).optional(),
|
|
176
178
|
continuity: z.boolean().optional(),
|
|
179
|
+
overlap: z.enum(["skip", "queue"]).optional(),
|
|
177
180
|
}).strict();
|
|
178
181
|
const storedChangesSchema = storedDefinitionSchema
|
|
179
182
|
.omit({ schedule: true, timeoutMinutes: true })
|
|
@@ -382,6 +385,7 @@ function normalizeDefinition(input, now) {
|
|
|
382
385
|
durationMinutes: duration(input.durationMinutes),
|
|
383
386
|
...(timeoutMinutes == null ? {} : { timeoutMinutes }),
|
|
384
387
|
...(input.continuity === true ? { continuity: true } : {}),
|
|
388
|
+
...(input.overlap === "queue" ? { overlap: "queue" } : {}),
|
|
385
389
|
};
|
|
386
390
|
}
|
|
387
391
|
function normalizeChanges(input, now) {
|
|
@@ -400,6 +404,8 @@ function normalizeChanges(input, now) {
|
|
|
400
404
|
changes.timeoutMinutes = timeout(input.timeoutMinutes);
|
|
401
405
|
if (input.continuity !== undefined)
|
|
402
406
|
changes.continuity = input.continuity === true;
|
|
407
|
+
if (input.overlap !== undefined)
|
|
408
|
+
changes.overlap = input.overlap;
|
|
403
409
|
return changes;
|
|
404
410
|
}
|
|
405
411
|
function routineId(value) {
|
|
@@ -619,6 +625,7 @@ function effectiveDefinition(operation, manager) {
|
|
|
619
625
|
durationMinutes: existing.durationMinutes,
|
|
620
626
|
...(existing.timeoutMinutes === undefined ? {} : { timeoutMinutes: existing.timeoutMinutes }),
|
|
621
627
|
...(existing.continuity ? { continuity: true } : {}),
|
|
628
|
+
...(existing.overlap ? { overlap: existing.overlap } : {}),
|
|
622
629
|
};
|
|
623
630
|
if (operation.action !== "update")
|
|
624
631
|
return base;
|
|
@@ -654,7 +661,7 @@ function cardCopy(operation, manager, timeZone, now) {
|
|
|
654
661
|
const nextRunAt = nextForOperation(operation, manager, now);
|
|
655
662
|
const scheduleTimeZone = definition.schedule.type === "cron" ? definition.schedule.timeZone : timeZone;
|
|
656
663
|
const when = operation.action === "run_now" ? "Now" : scheduleText(definition.schedule, timeZone);
|
|
657
|
-
const destination = definition.runOn === "cloud" ? "
|
|
664
|
+
const destination = definition.runOn === "cloud" ? "Box-hosted agent" : "Bot’s current model and configured computer";
|
|
658
665
|
const current = operation.action === "create"
|
|
659
666
|
? null
|
|
660
667
|
: manager.listRoutines().find((routine) => routine.id === operation.routineId) ?? null;
|
|
@@ -699,6 +706,7 @@ function cardCopy(operation, manager, timeZone, now) {
|
|
|
699
706
|
`Runs on: ${destination}`,
|
|
700
707
|
`Run limit: ${definition.timeoutMinutes === undefined ? "No limit" : `${definition.timeoutMinutes} minutes`}`,
|
|
701
708
|
`Continuity: ${definition.continuity ? "Carries the previous run's report into the next run" : "Each run starts fresh"}`,
|
|
709
|
+
`While busy: ${definition.overlap === "queue" ? "Queue one scheduled run; skip further occurrences until it starts" : "Skip overlapping scheduled occurrences"}`,
|
|
702
710
|
// Last before the instructions: the one sentence that says what
|
|
703
711
|
// confirming actually does, in the reader's terms.
|
|
704
712
|
...(operation.action === "create" || operation.action === "update"
|
|
@@ -723,6 +731,7 @@ function inputFromDefinition(definition, botId, now) {
|
|
|
723
731
|
durationMinutes: definition.durationMinutes,
|
|
724
732
|
...(definition.timeoutMinutes === undefined ? {} : { timeoutMinutes: definition.timeoutMinutes }),
|
|
725
733
|
...(definition.continuity ? { continuity: true } : {}),
|
|
734
|
+
...(definition.overlap === "queue" ? { overlap: "queue" } : {}),
|
|
726
735
|
};
|
|
727
736
|
}
|
|
728
737
|
function updateFromChanges(changes, now, currentSchedule) {
|
|
@@ -741,6 +750,8 @@ function updateFromChanges(changes, now, currentSchedule) {
|
|
|
741
750
|
patch.timeoutMinutes = changes.timeoutMinutes;
|
|
742
751
|
if (changes.continuity !== undefined)
|
|
743
752
|
patch.continuity = changes.continuity;
|
|
753
|
+
if (changes.overlap !== undefined)
|
|
754
|
+
patch.overlap = changes.overlap;
|
|
744
755
|
return patch;
|
|
745
756
|
}
|
|
746
757
|
function canonicalValue(value) {
|
|
@@ -793,6 +804,33 @@ function requestCommit(payload, messageId) {
|
|
|
793
804
|
};
|
|
794
805
|
}
|
|
795
806
|
function revalidateOperation(operation, manager, botId, now) {
|
|
807
|
+
if (operation.action === "create") {
|
|
808
|
+
const definition = operation.routine;
|
|
809
|
+
const owner = operation.forBot?.botId ?? botId;
|
|
810
|
+
const schedule = asSchedule(definition.schedule, now);
|
|
811
|
+
const duplicate = manager.listRoutines().find((routine) => {
|
|
812
|
+
if (!routine.enabled || routine.target !== "bot" || routine.botId !== owner
|
|
813
|
+
|| routine.runOn !== definition.runOn || routine.prompt !== definition.instructions
|
|
814
|
+
|| routine.durationMinutes !== definition.durationMinutes
|
|
815
|
+
|| routine.timeoutMinutes !== definition.timeoutMinutes
|
|
816
|
+
|| Boolean(routine.continuity) !== Boolean(definition.continuity)
|
|
817
|
+
|| (routine.overlap ?? "skip") !== (definition.overlap ?? "skip")
|
|
818
|
+
|| (routine.attachments?.length ?? 0) > 0)
|
|
819
|
+
return false;
|
|
820
|
+
// An omitted start means "every N minutes", not a new phase each time
|
|
821
|
+
// the model retries. Explicit starts and all other constraints stay exact.
|
|
822
|
+
const candidate = schedule.type === "interval" && routine.schedule.type === "interval"
|
|
823
|
+
&& definition.schedule.type === "interval" && definition.schedule.anchorAt === undefined
|
|
824
|
+
? { ...schedule, anchorAt: routine.schedule.anchorAt }
|
|
825
|
+
: schedule;
|
|
826
|
+
return isDeepStrictEqual(candidate, routine.schedule);
|
|
827
|
+
});
|
|
828
|
+
if (duplicate) {
|
|
829
|
+
// The tool cannot choose a result destination. Return the existing ID,
|
|
830
|
+
// without moving its reports or treating a renamed request as new work.
|
|
831
|
+
throw new RoutineRequestError(`An enabled routine with the same instructions and execution settings already exists (${duplicate.id}). Use list_routines to review it, then update or run that routine instead.`, 409);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
796
834
|
const current = operation.action === "create"
|
|
797
835
|
? null
|
|
798
836
|
: verifyManageSnapshot(operation, manager, botId);
|
|
@@ -6,6 +6,7 @@ import { DATA_DIR } from "./config.js";
|
|
|
6
6
|
import { writeFileAtomic } from "./atomic.js";
|
|
7
7
|
import { redactSecretsInText } from "./redact.js";
|
|
8
8
|
import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
|
|
9
|
+
import { isRoutineProblemRun } from "../shared/routines.js";
|
|
9
10
|
const persistedSourceThreadId = z.string().trim().min(1).optional().catch(undefined);
|
|
10
11
|
const webhookRunReceiptSchema = z.object({
|
|
11
12
|
webhookId: z.string().min(1).max(200),
|
|
@@ -432,6 +433,9 @@ function sanitizeInput(input, after) {
|
|
|
432
433
|
throw new Error("Attachments can only run on this computer until cloud file staging is available");
|
|
433
434
|
}
|
|
434
435
|
const continuity = input.continuity === true;
|
|
436
|
+
if (input.overlap !== undefined && input.overlap !== "skip" && input.overlap !== "queue") {
|
|
437
|
+
throw new Error("Choose skip or queue for overlapping runs");
|
|
438
|
+
}
|
|
435
439
|
if (continuity && target === "room-goal") {
|
|
436
440
|
throw new Error("Room goals do not carry continuity yet");
|
|
437
441
|
}
|
|
@@ -448,6 +452,7 @@ function sanitizeInput(input, after) {
|
|
|
448
452
|
...(timeoutMinutes === undefined ? {} : { timeoutMinutes }),
|
|
449
453
|
attachments,
|
|
450
454
|
...(continuity ? { continuity: true } : {}),
|
|
455
|
+
...(input.overlap === "queue" ? { overlap: "queue" } : {}),
|
|
451
456
|
};
|
|
452
457
|
}
|
|
453
458
|
export class RoutineManager {
|
|
@@ -482,9 +487,13 @@ export class RoutineManager {
|
|
|
482
487
|
attachments: loadAttachments(routine.attachments),
|
|
483
488
|
sourceThreadId: persistedSourceThreadId.parse(routine.sourceThreadId),
|
|
484
489
|
resultsThreadId: persistedSourceThreadId.parse(routine.resultsThreadId),
|
|
490
|
+
overlap: routine.overlap === "queue" ? "queue" : undefined,
|
|
491
|
+
skippedRuns: Number.isSafeInteger(routine.skippedRuns) && routine.skippedRuns > 0 ? routine.skippedRuns : undefined,
|
|
492
|
+
lastSkippedAt: Number.isSafeInteger(routine.lastSkippedAt) && routine.lastSkippedAt >= 0 && routine.lastSkippedAt <= MAX_DATE_MS ? routine.lastSkippedAt : undefined,
|
|
485
493
|
};
|
|
486
494
|
if (loaded.timeoutMinutes === undefined)
|
|
487
495
|
delete loaded.timeoutMinutes;
|
|
496
|
+
delete loaded.failureStreak;
|
|
488
497
|
return [loaded];
|
|
489
498
|
})
|
|
490
499
|
: [];
|
|
@@ -564,7 +573,16 @@ export class RoutineManager {
|
|
|
564
573
|
}
|
|
565
574
|
}
|
|
566
575
|
listRoutines() {
|
|
567
|
-
return this.routines.map(
|
|
576
|
+
return this.routines.map(routine => this.routineWithHealth(routine));
|
|
577
|
+
}
|
|
578
|
+
routineWithHealth(routine) {
|
|
579
|
+
// Successful delegated/provider turns are not successful runs until the
|
|
580
|
+
// routine settles. Cancellation and missed schedules are not attempts.
|
|
581
|
+
const outcomes = this.runs.filter(run => run.routineId === routine.id && (run.status === "failed" || run.status === "completed")).reverse()
|
|
582
|
+
.sort((a, b) => (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt) || b.createdAt - a.createdAt);
|
|
583
|
+
const success = outcomes.findIndex(run => run.status === "completed");
|
|
584
|
+
const failures = success < 0 ? outcomes.length : success;
|
|
585
|
+
return { ...cloneRoutine(routine), ...(failures ? { failureStreak: failures } : {}) };
|
|
568
586
|
}
|
|
569
587
|
/** Whether this computer should stay awake for routines: a run is in
|
|
570
588
|
* flight, or an enabled routine is due within the horizon. The schedule
|
|
@@ -664,7 +682,7 @@ export class RoutineManager {
|
|
|
664
682
|
if (receipt) {
|
|
665
683
|
const committed = this.routines.find((routine) => routine.id === receipt.resultId);
|
|
666
684
|
if (committed)
|
|
667
|
-
return
|
|
685
|
+
return this.routineWithHealth(committed);
|
|
668
686
|
throw new Error("This routine request was already applied");
|
|
669
687
|
}
|
|
670
688
|
}
|
|
@@ -693,14 +711,14 @@ export class RoutineManager {
|
|
|
693
711
|
this.rememberRoutineRequest(request, routine.id, at);
|
|
694
712
|
}, discardResults);
|
|
695
713
|
this.emitRoutine(routine);
|
|
696
|
-
return
|
|
714
|
+
return this.routineWithHealth(routine);
|
|
697
715
|
}
|
|
698
716
|
update(id, patch, request) {
|
|
699
717
|
if (request) {
|
|
700
718
|
const receipt = this.matchingRoutineRequestReceipt(request);
|
|
701
719
|
if (receipt) {
|
|
702
720
|
const committed = this.routines.find((routine) => routine.id === receipt.resultId);
|
|
703
|
-
return committed ?
|
|
721
|
+
return committed ? this.routineWithHealth(committed) : null;
|
|
704
722
|
}
|
|
705
723
|
}
|
|
706
724
|
const routine = this.routines.find((r) => r.id === id);
|
|
@@ -720,6 +738,7 @@ export class RoutineManager {
|
|
|
720
738
|
timeoutMinutes: Object.hasOwn(patch, "timeoutMinutes") ? patch.timeoutMinutes : routine.timeoutMinutes,
|
|
721
739
|
attachments: patch.attachments ?? routine.attachments,
|
|
722
740
|
continuity: patch.continuity ?? routine.continuity,
|
|
741
|
+
overlap: Object.hasOwn(patch, "overlap") ? patch.overlap : routine.overlap,
|
|
723
742
|
}, now);
|
|
724
743
|
if (this.targetState(clean) === "missing")
|
|
725
744
|
throw new Error(this.missingTargetMessage(clean.target));
|
|
@@ -749,6 +768,8 @@ export class RoutineManager {
|
|
|
749
768
|
// rather than false, so switching continuity off has to delete it.
|
|
750
769
|
if (!clean.continuity)
|
|
751
770
|
delete routine.continuity;
|
|
771
|
+
if (clean.overlap !== "queue")
|
|
772
|
+
delete routine.overlap;
|
|
752
773
|
if (Object.hasOwn(patch, "timeoutMinutes") && patch.timeoutMinutes == null) {
|
|
753
774
|
delete routine.timeoutMinutes;
|
|
754
775
|
}
|
|
@@ -769,7 +790,7 @@ export class RoutineManager {
|
|
|
769
790
|
for (const run of cancelledRuns)
|
|
770
791
|
this.emitRun(run);
|
|
771
792
|
this.emitRoutine(routine);
|
|
772
|
-
return
|
|
793
|
+
return this.routineWithHealth(routine);
|
|
773
794
|
}
|
|
774
795
|
remove(id, request) {
|
|
775
796
|
if (request) {
|
|
@@ -1043,6 +1064,27 @@ export class RoutineManager {
|
|
|
1043
1064
|
}
|
|
1044
1065
|
return cloneRun(run);
|
|
1045
1066
|
}
|
|
1067
|
+
/** Clear every failure indicator at once: stamp seenAt on each unseen
|
|
1068
|
+
* failed/missed run in one committed save, then emit the updated runs so
|
|
1069
|
+
* connected clients drop their dots immediately. A failed save rolls the
|
|
1070
|
+
* stamps back so a retry still finds the unseen runs. */
|
|
1071
|
+
markAllSeen() {
|
|
1072
|
+
if (!this.runs.some((run) => !run.seenAt && isRoutineProblemRun(run)))
|
|
1073
|
+
return [];
|
|
1074
|
+
const stampAt = this.now();
|
|
1075
|
+
const updated = [];
|
|
1076
|
+
this.commitMutation(() => {
|
|
1077
|
+
for (const run of this.runs) {
|
|
1078
|
+
if (run.seenAt || !isRoutineProblemRun(run))
|
|
1079
|
+
continue;
|
|
1080
|
+
run.seenAt = stampAt;
|
|
1081
|
+
updated.push(run);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
for (const run of updated)
|
|
1085
|
+
this.emitRun(run);
|
|
1086
|
+
return updated.map(cloneRun);
|
|
1087
|
+
}
|
|
1046
1088
|
get isTicking() { return this.ticking; }
|
|
1047
1089
|
start() {
|
|
1048
1090
|
if (this.timer)
|
|
@@ -1098,8 +1140,10 @@ export class RoutineManager {
|
|
|
1098
1140
|
: pendingAt;
|
|
1099
1141
|
// Frequent recurring work must not build an unbounded queue of stale
|
|
1100
1142
|
// copies. Elapsed intervals keep their phase; cron keeps its calendar.
|
|
1101
|
-
const overlapping =
|
|
1102
|
-
|
|
1143
|
+
const overlapping = routine.schedule.type !== "once" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
|
|
1144
|
+
const scheduledQueued = this.runs.some(run => run.routineId === routine.id && run.status === "queued" &&
|
|
1145
|
+
(run.triggerSource ?? (run.manual ? "manual" : "schedule")) === "schedule");
|
|
1146
|
+
if (!overlapping || (routine.overlap === "queue" && !scheduledQueued)) {
|
|
1103
1147
|
const run = this.newRun(routine, scheduledFor, false, allocations);
|
|
1104
1148
|
if (late > CATCH_UP_MS) {
|
|
1105
1149
|
run.status = "missed";
|
|
@@ -1108,6 +1152,10 @@ export class RoutineManager {
|
|
|
1108
1152
|
}
|
|
1109
1153
|
scheduledRuns.push(run);
|
|
1110
1154
|
}
|
|
1155
|
+
else {
|
|
1156
|
+
routine.skippedRuns = Math.min(Number.MAX_SAFE_INTEGER, (routine.skippedRuns ?? 0) + 1);
|
|
1157
|
+
routine.lastSkippedAt = scheduledFor;
|
|
1158
|
+
}
|
|
1111
1159
|
routine.nextRunAt =
|
|
1112
1160
|
routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor));
|
|
1113
1161
|
// `updatedAt` is the optimistic definition revision carried by
|
|
@@ -1170,7 +1218,10 @@ export class RoutineManager {
|
|
|
1170
1218
|
continue;
|
|
1171
1219
|
}
|
|
1172
1220
|
}
|
|
1173
|
-
|
|
1221
|
+
// A bot can have spare thread slots while this routine is waiting on
|
|
1222
|
+
// a teammate. Queue means after THIS run, not merely a free bot slot.
|
|
1223
|
+
const sameRoutineWorking = triggerSource === "schedule" && this.runs.some(other => other.id !== run.id && other.routineId === run.routineId && ["running", "waiting"].includes(other.status));
|
|
1224
|
+
const state = sameRoutineWorking ? "busy" : this.targetState(run);
|
|
1174
1225
|
if (state === "busy") {
|
|
1175
1226
|
// A queued run behind a busy target is deferred, not silent. Stamp
|
|
1176
1227
|
// the wait once so receipts and cards can say how long it has been
|
|
@@ -1453,11 +1504,16 @@ export class RoutineManager {
|
|
|
1453
1504
|
routine.resultsThreadId = value.trim();
|
|
1454
1505
|
}
|
|
1455
1506
|
emitRoutine(routine) {
|
|
1456
|
-
this.options.emit?.({ kind: "routine", routine:
|
|
1507
|
+
this.options.emit?.({ kind: "routine", routine: this.routineWithHealth(routine) });
|
|
1457
1508
|
}
|
|
1458
1509
|
emitRun(run) {
|
|
1459
1510
|
this.options.emit?.({ kind: "routine.run", run: cloneRun(run) });
|
|
1460
1511
|
this.notifyRunChanged(run);
|
|
1512
|
+
if (run.status === "completed" || run.status === "failed") {
|
|
1513
|
+
const routine = this.routines.find(candidate => candidate.id === run.routineId);
|
|
1514
|
+
if (routine)
|
|
1515
|
+
this.emitRoutine(routine);
|
|
1516
|
+
}
|
|
1461
1517
|
}
|
|
1462
1518
|
notifyRunChanged(run) {
|
|
1463
1519
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** A local desktop call competes with ordinary bot turns for the same screen.
|
|
2
2
|
* Leases expire if Electron crashes; human takeover always wins renewal. */
|
|
3
|
+
const LEASE_EXPIRY_MS = 40_000;
|
|
3
4
|
export class SharedComputerControl {
|
|
4
5
|
leases = new Map();
|
|
5
6
|
resources;
|
|
@@ -11,12 +12,38 @@ export class SharedComputerControl {
|
|
|
11
12
|
this.release(id);
|
|
12
13
|
throw Object.assign(new Error("This computer is in use locally or held by a person. Wait, then observe it again before acting."), { status: 409 });
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
this.arm(id);
|
|
16
|
+
}
|
|
17
|
+
/** Extends a held lease for one long desktop action without re-running the
|
|
18
|
+
* takeover checks: a heartbeat while the operation is pending must not be
|
|
19
|
+
* able to lose its own screen. Renewing an expired or released id is a
|
|
20
|
+
* no-op — the lease is gone and only acquire can bring it back. */
|
|
21
|
+
renew(id) {
|
|
22
|
+
if (this.leases.has(id))
|
|
23
|
+
this.arm(id);
|
|
24
|
+
}
|
|
25
|
+
/** (Re)arm the expiry timer. The generation counter makes a stale timer a
|
|
26
|
+
* no-op: only the callback of the newest arming may release the lease. */
|
|
27
|
+
arm(id) {
|
|
28
|
+
const previous = this.leases.get(id);
|
|
29
|
+
if (previous)
|
|
30
|
+
clearTimeout(previous.timer);
|
|
31
|
+
const generation = (previous?.generation ?? 0) + 1;
|
|
32
|
+
this.leases.set(id, {
|
|
33
|
+
timer: setTimeout(() => {
|
|
34
|
+
if (this.leases.get(id)?.generation === generation)
|
|
35
|
+
this.release(id);
|
|
36
|
+
}, LEASE_EXPIRY_MS),
|
|
37
|
+
generation,
|
|
38
|
+
lastActivity: Date.now(),
|
|
39
|
+
});
|
|
16
40
|
}
|
|
17
41
|
release(id) {
|
|
18
|
-
|
|
19
|
-
|
|
42
|
+
const lease = this.leases.get(id);
|
|
43
|
+
if (lease) {
|
|
44
|
+
clearTimeout(lease.timer);
|
|
45
|
+
this.leases.delete(id);
|
|
46
|
+
}
|
|
20
47
|
this.resources.release({ threadId: `shared:${id}`, generation: id });
|
|
21
48
|
}
|
|
22
49
|
close() { for (const id of this.leases.keys())
|
|
@@ -1,33 +1,76 @@
|
|
|
1
|
+
// Spend limits over the usage ledger: what this workspace has spent this
|
|
2
|
+
// month against its cap, the refusal a turn gets once the cap is reached,
|
|
3
|
+
// and the one-per-month notices when the month crosses the warning
|
|
4
|
+
// threshold and the cap. The figure is every cost in the ledger: what
|
|
5
|
+
// engines reported (on a workspace of personal subscriptions, their
|
|
6
|
+
// equivalents too) plus the estimates booked for engines that report tokens
|
|
7
|
+
// but no price (server/model-prices.ts). A turn on an unpriced model counts
|
|
8
|
+
// nothing. Enforced only with the `budgets` entitlement; without it the
|
|
9
|
+
// setting is inert.
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
1
12
|
import { entitled } from "./enterprise.js";
|
|
2
|
-
import { readUsage } from "./usage-ledger.js";
|
|
13
|
+
import { readUsage, usageRowKey } from "./usage-ledger.js";
|
|
3
14
|
const DEFAULT_WARN_AT_PERCENT = 80;
|
|
4
15
|
const CACHE_MS = 15_000;
|
|
5
16
|
// Every turn start asks; reading the month file each time would be silly.
|
|
17
|
+
// A read keeps the keys of the rows it summed, so a booked row can be told
|
|
18
|
+
// apart from the same row read back later.
|
|
6
19
|
const cache = new Map();
|
|
20
|
+
// Booked turns whose ledger row may not be on disk yet: dataDir → row key →
|
|
21
|
+
// cost. The append is asynchronous, and a turn that ran longer than CACHE_MS
|
|
22
|
+
// would otherwise be re-read out of the month before its own row landed.
|
|
23
|
+
const unwritten = new Map();
|
|
7
24
|
function monthOf(now) {
|
|
8
25
|
return now.toISOString().slice(0, 7);
|
|
9
26
|
}
|
|
10
|
-
|
|
27
|
+
const costOf = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
|
|
28
|
+
/** Reported and estimated cost this month so far: the ledger file (read at
|
|
29
|
+
* most every CACHE_MS) plus turns booked since whose rows have not landed. */
|
|
11
30
|
export function monthToDateSpend(dataDir, now = new Date()) {
|
|
12
31
|
const month = monthOf(now);
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return spentUsd;
|
|
20
|
-
}
|
|
21
|
-
/** Called right after a turn is booked, so the next check sees it without
|
|
22
|
-
* waiting for the ledger's append to land or the cache to expire. */
|
|
23
|
-
export function noteSpend(dataDir, costUsd, now = new Date()) {
|
|
24
|
-
const hit = cache.get(dataDir);
|
|
25
|
-
if (hit && hit.month === monthOf(now) && typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd > 0) {
|
|
26
|
-
hit.spentUsd += costUsd;
|
|
32
|
+
let read = cache.get(dataDir);
|
|
33
|
+
if (!read || read.month !== month || now.getTime() - read.at >= CACHE_MS) {
|
|
34
|
+
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
35
|
+
const rows = readUsage(dataDir, { from, to: now });
|
|
36
|
+
read = { at: now.getTime(), month, fileUsd: rows.reduce((sum, row) => sum + costOf(row.costUsd), 0), keys: new Set(rows.map(usageRowKey)) };
|
|
37
|
+
cache.set(dataDir, read);
|
|
27
38
|
}
|
|
39
|
+
let pending = 0;
|
|
40
|
+
for (const [key, entry] of unwritten.get(dataDir) ?? []) {
|
|
41
|
+
if (entry.month === month && !read.keys.has(key))
|
|
42
|
+
pending += entry.costUsd;
|
|
43
|
+
}
|
|
44
|
+
return read.fileUsd + pending;
|
|
45
|
+
}
|
|
46
|
+
/** Called as a turn is booked, with the ledger's write for its row
|
|
47
|
+
* (appendUsage's promise). The cost counts from memory at once; when the
|
|
48
|
+
* row lands it moves into the cached read, and a re-read that already sees
|
|
49
|
+
* the row never counts it twice. A write that failed stops counting, as
|
|
50
|
+
* the ledger has no row for it. */
|
|
51
|
+
export function noteSpend(dataDir, row, written) {
|
|
52
|
+
const costUsd = costOf(row.costUsd);
|
|
53
|
+
if (!costUsd)
|
|
54
|
+
return;
|
|
55
|
+
const key = usageRowKey(row);
|
|
56
|
+
const month = row.at.slice(0, 7);
|
|
57
|
+
const entries = unwritten.get(dataDir) ?? new Map();
|
|
58
|
+
unwritten.set(dataDir, entries);
|
|
59
|
+
entries.set(key, { month, costUsd });
|
|
60
|
+
void written.then((landed) => {
|
|
61
|
+
entries.delete(key);
|
|
62
|
+
if (entries.size === 0 && unwritten.get(dataDir) === entries)
|
|
63
|
+
unwritten.delete(dataDir);
|
|
64
|
+
const read = cache.get(dataDir);
|
|
65
|
+
if (landed && read && read.month === month && !read.keys.has(key)) {
|
|
66
|
+
read.fileUsd += costUsd;
|
|
67
|
+
read.keys.add(key);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
28
70
|
}
|
|
29
71
|
export function resetSpendCacheForTests() {
|
|
30
72
|
cache.clear();
|
|
73
|
+
unwritten.clear();
|
|
31
74
|
}
|
|
32
75
|
/** The cap and where the month stands against it; null when there is no
|
|
33
76
|
* enforceable cap (no entitlement, or none set). */
|
|
@@ -59,3 +102,55 @@ export function assertWithinBudget(cfg, dataDir, now = new Date(), isEntitled =
|
|
|
59
102
|
return;
|
|
60
103
|
throw Object.assign(new Error(`this workspace has reached its monthly spend limit of $${usd(state.monthlyUsd)} — an admin can raise it under Settings → Usage`), { status: 409, code: "spend_cap" });
|
|
61
104
|
}
|
|
105
|
+
const ALERTS_FILE = "alerts.json";
|
|
106
|
+
function readMarks(dataDir) {
|
|
107
|
+
try {
|
|
108
|
+
const value = JSON.parse(readFileSync(join(dataDir, "usage", ALERTS_FILE), "utf8"));
|
|
109
|
+
if (typeof value !== "object" || value === null)
|
|
110
|
+
return null;
|
|
111
|
+
const marks = value;
|
|
112
|
+
return typeof marks.month === "string" && typeof marks.monthlyUsd === "number" ? marks : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** The notice this booking should raise, if any: the cap the first time the
|
|
119
|
+
* month reaches it, the warning the first time it crosses the threshold, and
|
|
120
|
+
* nothing again for either that month. A new month, or a different cap, is a
|
|
121
|
+
* new budget and starts over. Marks live beside the ledger so a restart does
|
|
122
|
+
* not repeat them; if they cannot be written the notice is still sent once
|
|
123
|
+
* for this run. */
|
|
124
|
+
export function takeSpendAlert(dataDir, state) {
|
|
125
|
+
if (!state || (!state.warn && !state.exceeded))
|
|
126
|
+
return null;
|
|
127
|
+
const saved = readMarks(dataDir) ?? memoryMarks.get(dataDir) ?? null;
|
|
128
|
+
const marks = saved && saved.month === state.month && saved.monthlyUsd === state.monthlyUsd
|
|
129
|
+
? saved
|
|
130
|
+
: { month: state.month, monthlyUsd: state.monthlyUsd };
|
|
131
|
+
const alert = state.exceeded && !marks.cap ? "cap" : state.warn && !marks.warn && !marks.cap ? "warn" : null;
|
|
132
|
+
if (!alert)
|
|
133
|
+
return null;
|
|
134
|
+
// Reaching the cap also answers the warning: one notice, the stronger one.
|
|
135
|
+
const next = { ...marks, warn: true, ...(alert === "cap" ? { cap: true } : {}) };
|
|
136
|
+
memoryMarks.set(dataDir, next);
|
|
137
|
+
try {
|
|
138
|
+
mkdirSync(join(dataDir, "usage"), { recursive: true, mode: 0o700 });
|
|
139
|
+
writeFileSync(join(dataDir, "usage", ALERTS_FILE), JSON.stringify(next), { mode: 0o600 });
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* bookkeeping must never take down the turn */
|
|
143
|
+
}
|
|
144
|
+
return alert;
|
|
145
|
+
}
|
|
146
|
+
const memoryMarks = new Map();
|
|
147
|
+
export function resetSpendAlertsForTests() {
|
|
148
|
+
memoryMarks.clear();
|
|
149
|
+
}
|
|
150
|
+
/** The notice's words. English like the server's other notification titles. */
|
|
151
|
+
export function spendAlertText(alert, state) {
|
|
152
|
+
const spent = `$${state.spentUsd.toFixed(2)} of $${usd(state.monthlyUsd)} spent this month (${state.month})`;
|
|
153
|
+
return alert === "cap"
|
|
154
|
+
? { title: "Monthly spend limit reached", body: `${spent}. New turns are refused until an admin raises the limit under Settings → Usage.` }
|
|
155
|
+
: { title: `Spend is at ${state.percent}% of the monthly limit`, body: `${spent}. New turns stop when the limit is reached.` };
|
|
156
|
+
}
|