openmausbot 0.1.76 → 0.1.78
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/{index-C6blGSq5.js → index-BqDJqf2C.js} +1 -1
- package/dist/assets/index-DjwAroIZ.js +305 -0
- package/dist/assets/index-Pbb6Ao0s.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +838 -30
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +4097 -2063
- package/dist-server/openmausbot.js +730 -523
- package/dist-server/pair-cli.js +730 -523
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/auto-approve.js +16 -0
- package/dist-server/server/bot-overview.js +6 -2
- package/dist-server/server/bot-package.js +17 -3
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/chief-of-staff.js +2 -2
- package/dist-server/server/config.js +16 -0
- package/dist-server/server/drivers/acp/core.js +3 -1
- package/dist-server/server/drivers/agents-proxy.js +132 -27
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +3 -2
- package/dist-server/server/drivers/codex.js +3 -1
- package/dist-server/server/drivers/pi.js +4 -0
- package/dist-server/server/enterprise.js +38 -0
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/fleet-agent.js +57 -13
- package/dist-server/server/fleet-cli.js +248 -31
- package/dist-server/server/fleet.js +88 -14
- package/dist-server/server/hosted-contract.js +16 -0
- package/dist-server/server/index.js +877 -197
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/package-export.js +16 -14
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/profile-requests.js +26 -4
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/room-handoffs.js +38 -1
- package/dist-server/server/routine-requests.js +86 -3
- package/dist-server/server/routines.js +31 -13
- package/dist-server/server/sessions.js +115 -8
- package/dist-server/server/setup-mode.js +11 -13
- package/dist-server/server/shared-computer-control.js +24 -0
- package/dist-server/server/shared-computers.js +131 -0
- package/dist-server/server/skill-learn.js +4 -4
- package/dist-server/server/skills.js +21 -0
- package/dist-server/server/steer-queue.js +2 -0
- package/dist-server/server/store.js +110 -8
- package/dist-server/server/system-prompt.js +7 -5
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/team-setup-requests.js +83 -31
- package/dist-server/server/tool-summary.js +53 -1
- package/dist-server/server/workspace-backup.js +1 -0
- package/dist-server/shared/approval-mode.js +4 -1
- package/dist-server/shared/cron-label.js +39 -0
- package/dist-server/shared/routine-schedule.js +89 -0
- package/dist-server/shared/team-backup.js +1 -1
- package/dist-server/shared/team-computer.js +1 -0
- package/dist-server/vps-container-mcp.js +5 -1
- package/enterprise/README.md +21 -3
- package/enterprise/server/index.js +387 -29
- package/package.json +1 -1
- package/dist/assets/index-BtT047dk.css +0 -1
- package/dist/assets/index-fKXFpB_J.js +0 -305
|
@@ -326,6 +326,10 @@ function mimeFor(path) {
|
|
|
326
326
|
case ".jpeg": return "image/jpeg";
|
|
327
327
|
case ".gif": return "image/gif";
|
|
328
328
|
case ".webp": return "image/webp";
|
|
329
|
+
case ".mp4": return "video/mp4";
|
|
330
|
+
case ".m4v": return "video/x-m4v";
|
|
331
|
+
case ".webm": return "video/webm";
|
|
332
|
+
case ".mov": return "video/quicktime";
|
|
329
333
|
case ".doc": return "application/msword";
|
|
330
334
|
case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
331
335
|
case ".xls": return "application/vnd.ms-excel";
|
|
@@ -111,20 +111,22 @@ export function createBotPackageExport(input) {
|
|
|
111
111
|
runOn: routine.runOn,
|
|
112
112
|
schedule: routine.schedule.type === "once"
|
|
113
113
|
? { type: "once", at: routine.schedule.at }
|
|
114
|
-
: routine.schedule.type === "
|
|
115
|
-
? {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
114
|
+
: routine.schedule.type === "cron"
|
|
115
|
+
? { ...routine.schedule }
|
|
116
|
+
: routine.schedule.type === "interval"
|
|
117
|
+
? {
|
|
118
|
+
type: "interval",
|
|
119
|
+
everyMinutes: routine.schedule.everyMinutes,
|
|
120
|
+
anchorAt: routine.schedule.anchorAt,
|
|
121
|
+
...(routine.schedule.weekdays === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { weekdays: [...routine.schedule.weekdays] }),
|
|
124
|
+
...(routine.schedule.window === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: { window: { ...routine.schedule.window } }),
|
|
127
|
+
...(routine.schedule.endsAt === undefined ? {} : { endsAt: routine.schedule.endsAt }),
|
|
128
|
+
}
|
|
129
|
+
: { type: "daily", time: routine.schedule.time, weekdays: [...routine.schedule.weekdays] },
|
|
128
130
|
durationMinutes: routine.durationMinutes,
|
|
129
131
|
...(routine.timeoutMinutes === undefined ? {} : { timeoutMinutes: routine.timeoutMinutes }),
|
|
130
132
|
enabledAfterInstall: false,
|
|
@@ -106,7 +106,7 @@ function announceCard(bus, from, card, sourceThreadId) {
|
|
|
106
106
|
* covers the (action, target) pair, returns `"allow"` immediately
|
|
107
107
|
* without a card. */
|
|
108
108
|
export function requestPeerApproval(bus, from, target, message, action, sourceThreadId = from.threadId) {
|
|
109
|
-
if (allowKeyAllowed(from, peerAllowKey(action, target.id))) {
|
|
109
|
+
if (bus.autoApply?.(from.id, sourceThreadId) || allowKeyAllowed(from, peerAllowKey(action, target.id))) {
|
|
110
110
|
return Promise.resolve("allow");
|
|
111
111
|
}
|
|
112
112
|
return new Promise((resolve) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// propose_profile: a bot proposes changes to its own name, title, description,
|
|
2
|
-
// SOUL.md, or working folder;
|
|
2
|
+
// SOUL.md, or working folder; confirmed cards and Full Access submissions share
|
|
3
|
+
// the same validated commit path. Same
|
|
3
4
|
// shape as routine-requests.ts, much smaller: the profile commits in one
|
|
4
5
|
// store call, and staleness is a hash of the five fields instead of a
|
|
5
6
|
// scheduler revision. Everything here is re-validated at confirm time —
|
|
@@ -163,6 +164,7 @@ export class ProfileRequestService {
|
|
|
163
164
|
store;
|
|
164
165
|
now;
|
|
165
166
|
canPersist;
|
|
167
|
+
autoApply;
|
|
166
168
|
/** Public: a caller's section membership can change between propose and
|
|
167
169
|
* confirm, and tests flip this mid-scenario to model that. */
|
|
168
170
|
validateTarget;
|
|
@@ -170,9 +172,17 @@ export class ProfileRequestService {
|
|
|
170
172
|
this.store = options.store;
|
|
171
173
|
this.now = options.now ?? Date.now;
|
|
172
174
|
this.canPersist = options.canPersist;
|
|
175
|
+
this.autoApply = options.autoApply;
|
|
173
176
|
this.validateTarget = options.validateTarget;
|
|
174
177
|
}
|
|
175
178
|
propose(args) {
|
|
179
|
+
return this.prepare(args);
|
|
180
|
+
}
|
|
181
|
+
submit(args) {
|
|
182
|
+
const proposal = this.prepare(args, true);
|
|
183
|
+
return { ...proposal, state: proposal.result ? "applied" : "pending" };
|
|
184
|
+
}
|
|
185
|
+
prepare(args, submitted = false) {
|
|
176
186
|
const reason = reasonText(args.reason);
|
|
177
187
|
const changes = parseChanges(args.changes);
|
|
178
188
|
const targetBotId = args.targetBotId ?? args.botId;
|
|
@@ -220,13 +230,15 @@ export class ProfileRequestService {
|
|
|
220
230
|
if (persistence && !persistence.ok) {
|
|
221
231
|
throw new ProfileRequestError(persistence.error, persistence.status);
|
|
222
232
|
}
|
|
233
|
+
const automatic = submitted && this.autoApply?.(args.botId, args.threadId) === true;
|
|
223
234
|
const messageInput = {
|
|
224
235
|
role: "bot",
|
|
225
236
|
kind: "options",
|
|
226
237
|
card: {
|
|
227
238
|
title: copy.title,
|
|
228
239
|
subtitle: copy.detail,
|
|
229
|
-
options: ["Confirm", "Cancel"],
|
|
240
|
+
options: automatic ? [] : ["Confirm", "Cancel"],
|
|
241
|
+
...(automatic ? { dismissed: true } : {}),
|
|
230
242
|
requestId,
|
|
231
243
|
tool: "update_profile",
|
|
232
244
|
profileRequest: payload,
|
|
@@ -235,7 +247,15 @@ export class ProfileRequestService {
|
|
|
235
247
|
if (args.from)
|
|
236
248
|
messageInput.from = args.from;
|
|
237
249
|
const message = this.store.appendMessage(args.threadId, messageInput);
|
|
238
|
-
|
|
250
|
+
const proposal = { requestId, messageId: message.id, title: copy.title, summary: copy.summary, detail: copy.detail };
|
|
251
|
+
if (!automatic)
|
|
252
|
+
return proposal;
|
|
253
|
+
// The hidden receipt is persisted before the profile changes. The same
|
|
254
|
+
// validation and durable commit marker serve both automatic and human decisions.
|
|
255
|
+
const result = this.resolve({ botId: args.botId, threadId: args.threadId, requestId, behavior: "allow" });
|
|
256
|
+
if (result.state === "applied")
|
|
257
|
+
return { ...proposal, result };
|
|
258
|
+
throw new ProfileRequestError(result.state === "invalid" ? result.error : "The profile change could not be applied", result.state === "invalid" ? result.status : 409);
|
|
239
259
|
}
|
|
240
260
|
/** Claims a profile card even after it was settled, so a duplicate click
|
|
241
261
|
* never re-applies an already-applied change. */
|
|
@@ -313,7 +333,9 @@ export class ProfileRequestService {
|
|
|
313
333
|
const status = error instanceof ProfileRequestError ? error.status : 400;
|
|
314
334
|
const detail = error instanceof Error ? error.message : String(error);
|
|
315
335
|
const saved = this.store.bot(payload.targetBotId)?.lastProfileRequestId === payload.requestId;
|
|
316
|
-
const notice =
|
|
336
|
+
const notice = card.dismissed && card.options.length === 0
|
|
337
|
+
? "Profile saved. Recording the operation receipt could not finish; the changes will not be applied again."
|
|
338
|
+
: "Profile saved. Confirm again to finish recording this decision; the changes will not be applied again.";
|
|
317
339
|
try {
|
|
318
340
|
this.store.patchMessage(args.threadId, message.id, {
|
|
319
341
|
card: { ...card, held: saved ? notice : redactSecretsInText(detail).slice(0, 500) },
|
|
@@ -31,6 +31,8 @@ function modelIds(body) {
|
|
|
31
31
|
return ids;
|
|
32
32
|
}
|
|
33
33
|
export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_000) {
|
|
34
|
+
if (!input.key.trim())
|
|
35
|
+
return { ok: false, reason: "rejected" };
|
|
34
36
|
let url;
|
|
35
37
|
try {
|
|
36
38
|
url = new URL(providerModelsUrl(input.provider, input.url));
|
|
@@ -41,6 +43,10 @@ export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_
|
|
|
41
43
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
|
|
42
44
|
return { ok: false, reason: "unexpected" };
|
|
43
45
|
}
|
|
46
|
+
const authenticate = input.provider === "openaiCompat"
|
|
47
|
+
&& url.origin === "https://openrouter.ai" && url.pathname === "/api/v1/models";
|
|
48
|
+
if (authenticate)
|
|
49
|
+
url.pathname = "/api/v1/key";
|
|
44
50
|
const headers = input.provider === "anthropic"
|
|
45
51
|
? { "x-api-key": input.key, "anthropic-version": "2023-06-01" }
|
|
46
52
|
: { authorization: `Bearer ${input.key}` };
|
|
@@ -55,7 +61,18 @@ export async function checkProviderKey(input, fetchImpl = fetch, timeoutMs = 10_
|
|
|
55
61
|
if (!response.ok)
|
|
56
62
|
return { ok: false, reason: "unexpected", status: response.status };
|
|
57
63
|
const body = await response.json().catch(() => null);
|
|
58
|
-
|
|
64
|
+
if (!body || typeof body !== "object")
|
|
65
|
+
return { ok: false, reason: "unexpected", status: response.status };
|
|
66
|
+
if (authenticate) {
|
|
67
|
+
const data = body.data;
|
|
68
|
+
if (!data || typeof data !== "object" || Array.isArray(data)
|
|
69
|
+
|| typeof data.is_free_tier !== "boolean") {
|
|
70
|
+
return { ok: false, reason: "unexpected", status: response.status };
|
|
71
|
+
}
|
|
72
|
+
// /key also returns account and usage details; none belong in this verdict.
|
|
73
|
+
return { ok: true, check: "authentication", models: [] };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, check: "models", models: modelIds(body) };
|
|
59
76
|
}
|
|
60
77
|
catch {
|
|
61
78
|
return { ok: false, reason: "unreachable" };
|
|
@@ -157,6 +157,11 @@ export const CLIENT_ALLOW = [
|
|
|
157
157
|
{ methods: ["GET"], path: /^\/api\/auth\/session$/ },
|
|
158
158
|
{ methods: ["POST"], path: /^\/api\/auth\/stream-ticket$/ },
|
|
159
159
|
{ methods: ["POST"], path: /^\/api\/auth\/logout$/ },
|
|
160
|
+
// Own outbound desktop connector, additionally bound to a private secret.
|
|
161
|
+
// Only honoured while features.sharedComputers is on (see requiredScope):
|
|
162
|
+
// with the feature off these paths are as unlisted as any other, so a
|
|
163
|
+
// client session is refused exactly the way an unknown route refuses it.
|
|
164
|
+
{ methods: ["POST"], path: /^\/api\/shared-computers\/(?:connect|[\w-]+\/(?:poll|lease|result|disconnect))$/, feature: "sharedComputers" },
|
|
160
165
|
// liveness, identity, the stream
|
|
161
166
|
{ methods: ["GET"], path: /^\/api\/health$/ },
|
|
162
167
|
{ methods: ["GET"], path: /^\/api\/edition$/ },
|
|
@@ -218,9 +223,11 @@ export const CLIENT_ALLOW = [
|
|
|
218
223
|
// configured-or-not booleans; the handler strips the few identifying fields for clients
|
|
219
224
|
{ methods: ["GET"], path: /^\/api\/config$/ },
|
|
220
225
|
];
|
|
221
|
-
export function requiredScope(method, path) {
|
|
226
|
+
export function requiredScope(method, path, features = {}) {
|
|
222
227
|
const upper = method.toUpperCase();
|
|
223
228
|
for (const rule of CLIENT_ALLOW) {
|
|
229
|
+
if (rule.feature && features[rule.feature] !== true)
|
|
230
|
+
continue;
|
|
224
231
|
if (rule.path.test(path) && rule.methods.includes(upper))
|
|
225
232
|
return "client";
|
|
226
233
|
}
|
|
@@ -299,7 +306,7 @@ export function resolveRequestAuth(req, options) {
|
|
|
299
306
|
if (session && via) {
|
|
300
307
|
if (via === "cookie" && !isSameOrigin(req))
|
|
301
308
|
return deny(403, "forbidden: cross-origin request");
|
|
302
|
-
const needed = requiredScope(method, path);
|
|
309
|
+
const needed = requiredScope(method, path, options.features ?? {});
|
|
303
310
|
if (!session.scopes.includes(needed)) {
|
|
304
311
|
return deny(403, `forbidden: this session lacks the ${needed} scope`);
|
|
305
312
|
}
|
|
@@ -311,6 +318,11 @@ export function resolveRequestAuth(req, options) {
|
|
|
311
318
|
options.sessions.renew(session.id);
|
|
312
319
|
return { auth: { kind: "session", session, via, scopes: session.scopes }, status: 401, error: "" };
|
|
313
320
|
}
|
|
321
|
+
// A removed email member must not become the loopback owner merely because
|
|
322
|
+
// their now-invalid cookie or bearer was presented to a local address.
|
|
323
|
+
if (via) {
|
|
324
|
+
return deny(401, "unauthorized: this session has expired or was revoked; pair this device again");
|
|
325
|
+
}
|
|
314
326
|
const proxied = isProxied(req);
|
|
315
327
|
const loopback = !proxied && isLoopbackHost(headerValue(req.headers.host)) && isAllowedOrigin(headerValue(req.headers.origin));
|
|
316
328
|
if (loopback) {
|
|
@@ -330,9 +342,6 @@ export function resolveRequestAuth(req, options) {
|
|
|
330
342
|
}
|
|
331
343
|
return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
|
|
332
344
|
}
|
|
333
|
-
if (via) {
|
|
334
|
-
return deny(401, "unauthorized: this session has expired or was revoked; pair this device again");
|
|
335
|
-
}
|
|
336
345
|
if (proxied) {
|
|
337
346
|
return deny(403, "forbidden: this request came through a proxy (pair this device to use the server remotely)");
|
|
338
347
|
}
|
|
@@ -172,6 +172,41 @@ export class RoomHandoffs {
|
|
|
172
172
|
activeDirect(threadId) {
|
|
173
173
|
return [...this.nodes.values()].some(n => !n.groupId && n.threadId === threadId && !terminal(n));
|
|
174
174
|
}
|
|
175
|
+
/** Work this conversation handed out that has not settled yet. The
|
|
176
|
+
* conversation's own node is not outstanding — only what it waits on. */
|
|
177
|
+
outstandingDirect(threadId) {
|
|
178
|
+
return [...this.nodes.values()].filter(node => {
|
|
179
|
+
if (terminal(node) || !node.parentId)
|
|
180
|
+
return false;
|
|
181
|
+
const parent = this.nodes.get(node.parentId);
|
|
182
|
+
return Boolean(parent && !parent.groupId && parent.threadId === threadId);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** Stop this conversation without reaching into a teammate that is already
|
|
186
|
+
* working. Its provider process is left alone: it finishes and its result
|
|
187
|
+
* is still reported here. Work that never started is cancelled, because
|
|
188
|
+
* nothing is lost. This conversation stops being awaited either way, so no
|
|
189
|
+
* teammate result resumes a stopped chat. Returns what was left running. */
|
|
190
|
+
stopAwaitingDirect(threadId, reason = "Stopped by user") {
|
|
191
|
+
const left = [];
|
|
192
|
+
for (const node of this.nodes.values()) {
|
|
193
|
+
if (node.groupId || node.threadId !== threadId || terminal(node))
|
|
194
|
+
continue;
|
|
195
|
+
for (const child of this.children(node.id)) {
|
|
196
|
+
if (terminal(child))
|
|
197
|
+
continue;
|
|
198
|
+
if (child.status === "queued")
|
|
199
|
+
this.cancelTree(child, "Stopped before it started");
|
|
200
|
+
else
|
|
201
|
+
left.push(child);
|
|
202
|
+
}
|
|
203
|
+
node.status = "cancelled";
|
|
204
|
+
node.result = reason;
|
|
205
|
+
this.controllers.get(node.id)?.abort();
|
|
206
|
+
this.publish(node);
|
|
207
|
+
}
|
|
208
|
+
return left;
|
|
209
|
+
}
|
|
175
210
|
tick() {
|
|
176
211
|
if (this.loadError)
|
|
177
212
|
return;
|
|
@@ -200,7 +235,9 @@ export class RoomHandoffs {
|
|
|
200
235
|
// A newly queued child starts only after its author has settled.
|
|
201
236
|
if (parent && (parent.status === "source" || parent.status === "running"))
|
|
202
237
|
continue;
|
|
203
|
-
|
|
238
|
+
// A stopped source stops waiting; only work that never started is
|
|
239
|
+
// dropped with it. A teammate mid-turn keeps its process and reports.
|
|
240
|
+
if (parent && terminal(parent) && n.status === "queued") {
|
|
204
241
|
this.cancelTree(n, "Originating request has ended");
|
|
205
242
|
continue;
|
|
206
243
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { cronScheduleLabel } from "../shared/cron-label.js";
|
|
4
|
+
import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
|
|
3
5
|
import { newId } from "./contracts.js";
|
|
4
6
|
import { redactSecretsInText } from "./redact.js";
|
|
5
7
|
import { parseJson, schemaIssue } from "./schema.js";
|
|
@@ -33,6 +35,7 @@ const toolIntervalWindowSchema = z.object({
|
|
|
33
35
|
end: z.string().max(5),
|
|
34
36
|
}).strict();
|
|
35
37
|
const routineToolScheduleSchema = z.discriminatedUnion("type", [
|
|
38
|
+
z.object({ type: z.literal("cron"), expression: z.string().max(256), timeZone: z.string().max(128) }).strict(),
|
|
36
39
|
z.object({ type: z.literal("once"), at: z.string().max(64) }).strict(),
|
|
37
40
|
z.object({
|
|
38
41
|
type: z.literal("weekly"),
|
|
@@ -82,7 +85,18 @@ const storedIntervalWindowSchema = z.object({
|
|
|
82
85
|
start: z.string().regex(TIME),
|
|
83
86
|
end: z.string().regex(TIME),
|
|
84
87
|
}).strict().refine(({ start, end }) => start < end, "Stored interval window must end later on the same day");
|
|
88
|
+
const storedCronScheduleSchema = z.object({
|
|
89
|
+
type: z.literal("cron"), expression: z.string().max(256), timeZone: z.string().max(128),
|
|
90
|
+
}).strict().superRefine((schedule, context) => {
|
|
91
|
+
try {
|
|
92
|
+
normalizeCronSchedule(schedule);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
context.addIssue({ code: "custom", message: error instanceof Error ? error.message : "Invalid stored cron schedule" });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
85
98
|
const storedScheduleSchema = z.discriminatedUnion("type", [
|
|
99
|
+
storedCronScheduleSchema,
|
|
86
100
|
z.object({ type: z.literal("once"), at: z.number().int().nonnegative() }).strict(),
|
|
87
101
|
z.object({
|
|
88
102
|
type: z.literal("daily"),
|
|
@@ -116,6 +130,7 @@ const storedScheduleSchema = z.discriminatedUnion("type", [
|
|
|
116
130
|
}
|
|
117
131
|
});
|
|
118
132
|
const storedScheduleChangesSchema = z.discriminatedUnion("type", [
|
|
133
|
+
storedCronScheduleSchema,
|
|
119
134
|
z.object({ type: z.literal("once"), at: z.number().int().nonnegative() }).strict(),
|
|
120
135
|
z.object({
|
|
121
136
|
type: z.literal("daily"),
|
|
@@ -287,6 +302,14 @@ function rfc3339Instant(value, offsetMessage) {
|
|
|
287
302
|
return at;
|
|
288
303
|
}
|
|
289
304
|
function normalizeSchedule(schedule, now) {
|
|
305
|
+
if (schedule.type === "cron") {
|
|
306
|
+
try {
|
|
307
|
+
return normalizeCronSchedule(schedule, now);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
throw new RoutineRequestError(error instanceof Error ? error.message : "Invalid cron schedule");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
290
313
|
if (schedule.type === "once") {
|
|
291
314
|
const at = rfc3339Instant(schedule.at, "One-time schedules need an RFC3339 date-time with an explicit timezone offset");
|
|
292
315
|
if (at <= now)
|
|
@@ -424,6 +447,8 @@ function normalizedOperation(manager, botId, validated, now) {
|
|
|
424
447
|
return { action: validated.action, routineId: id, expectedUpdatedAt: current.updatedAt };
|
|
425
448
|
}
|
|
426
449
|
function asSchedule(schedule, now) {
|
|
450
|
+
if (schedule.type === "cron")
|
|
451
|
+
return { ...schedule };
|
|
427
452
|
if (schedule.type === "once")
|
|
428
453
|
return { type: "once", at: schedule.at };
|
|
429
454
|
if (schedule.type === "interval") {
|
|
@@ -455,6 +480,8 @@ function schedulePatch(schedule, now, current) {
|
|
|
455
480
|
};
|
|
456
481
|
}
|
|
457
482
|
function effectiveSchedule(current, incoming) {
|
|
483
|
+
if (incoming.type === "cron")
|
|
484
|
+
return { ...incoming };
|
|
458
485
|
if (incoming.type !== "interval") {
|
|
459
486
|
return incoming.type === "once"
|
|
460
487
|
? { type: "once", at: incoming.at }
|
|
@@ -524,6 +551,8 @@ function intervalHasRestrictions(schedule) {
|
|
|
524
551
|
return schedule.weekdays !== undefined || schedule.window !== undefined || schedule.endsAt !== undefined;
|
|
525
552
|
}
|
|
526
553
|
export function scheduleText(schedule, timeZone) {
|
|
554
|
+
if (schedule.type === "cron")
|
|
555
|
+
return `${cronScheduleLabel(schedule)} · Cron: ${schedule.expression}`;
|
|
527
556
|
if (schedule.type === "once")
|
|
528
557
|
return `${formatInstant(schedule.at, timeZone)} (${timeZone})`;
|
|
529
558
|
if (schedule.type === "interval") {
|
|
@@ -552,6 +581,8 @@ export function consequenceLine(schedule, continuity = false) {
|
|
|
552
581
|
// over is the previous run's report, so say that rather than contradict
|
|
553
582
|
// the Continuity line above it.
|
|
554
583
|
const session = continuity ? "each run starts a fresh session with the previous run's report" : "each run starts a fresh session";
|
|
584
|
+
if (schedule.type === "cron")
|
|
585
|
+
return `Will run at matching calendar times in ${schedule.timeZone}; ${session}.`;
|
|
555
586
|
if (schedule.type === "once")
|
|
556
587
|
return "Will run once; that run starts a fresh session.";
|
|
557
588
|
if (schedule.type === "interval") {
|
|
@@ -621,6 +652,7 @@ function cardCopy(operation, manager, timeZone, now) {
|
|
|
621
652
|
};
|
|
622
653
|
}
|
|
623
654
|
const nextRunAt = nextForOperation(operation, manager, now);
|
|
655
|
+
const scheduleTimeZone = definition.schedule.type === "cron" ? definition.schedule.timeZone : timeZone;
|
|
624
656
|
const when = operation.action === "run_now" ? "Now" : scheduleText(definition.schedule, timeZone);
|
|
625
657
|
const destination = definition.runOn === "cloud" ? "Cloud VM" : "This OpenMausBot setup";
|
|
626
658
|
const current = operation.action === "create"
|
|
@@ -638,7 +670,7 @@ function cardCopy(operation, manager, timeZone, now) {
|
|
|
638
670
|
? `Next allowed time after confirmation (${timeZone})`
|
|
639
671
|
: "One interval after confirmation"
|
|
640
672
|
: nextRunAt !== null
|
|
641
|
-
? formatInstant(nextRunAt,
|
|
673
|
+
? formatInstant(nextRunAt, scheduleTimeZone)
|
|
642
674
|
: operation.action === "pause"
|
|
643
675
|
? "None — this routine will be paused"
|
|
644
676
|
: operation.action === "delete"
|
|
@@ -661,6 +693,9 @@ function cardCopy(operation, manager, timeZone, now) {
|
|
|
661
693
|
...(forBot ? [`For: @${redactSecretsInText(forBot.name)} — each run uses that bot's engine and permissions`] : []),
|
|
662
694
|
`Schedule: ${when}`,
|
|
663
695
|
`Next run: ${nextDescription}`,
|
|
696
|
+
...(definition.schedule.type === "cron" && nextRunAt !== null && operation.action !== "run_now"
|
|
697
|
+
? [`Next 3 runs (${scheduleTimeZone}): ${nextCronRuns(definition.schedule, now, 3).map((at) => formatInstant(at, scheduleTimeZone)).join(" · ")}`]
|
|
698
|
+
: []),
|
|
664
699
|
`Runs on: ${destination}`,
|
|
665
700
|
`Run limit: ${definition.timeoutMinutes === undefined ? "No limit" : `${definition.timeoutMinutes} minutes`}`,
|
|
666
701
|
`Continuity: ${definition.continuity ? "Carries the previous run's report into the next run" : "Each run starts fresh"}`,
|
|
@@ -766,6 +801,14 @@ function revalidateOperation(operation, manager, botId, now) {
|
|
|
766
801
|
: operation.action === "update"
|
|
767
802
|
? operation.changes.schedule
|
|
768
803
|
: undefined;
|
|
804
|
+
if (schedule?.type === "cron") {
|
|
805
|
+
try {
|
|
806
|
+
normalizeCronSchedule(schedule, now);
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
throw new RoutineRequestError(error instanceof Error ? error.message : "Invalid cron schedule", 409);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
769
812
|
if (schedule?.type === "once" && schedule.at <= now) {
|
|
770
813
|
throw new RoutineRequestError("That one-time schedule is now in the past. Ask the bot to propose a new time.", 409);
|
|
771
814
|
}
|
|
@@ -810,6 +853,7 @@ export class RoutineRequestService {
|
|
|
810
853
|
cloudReady;
|
|
811
854
|
canPersist;
|
|
812
855
|
validateTarget;
|
|
856
|
+
autoApply;
|
|
813
857
|
constructor(options) {
|
|
814
858
|
this.store = options.store;
|
|
815
859
|
this.routines = options.routines;
|
|
@@ -818,8 +862,16 @@ export class RoutineRequestService {
|
|
|
818
862
|
this.cloudReady = options.cloudReady;
|
|
819
863
|
this.canPersist = options.canPersist;
|
|
820
864
|
this.validateTarget = options.validateTarget;
|
|
865
|
+
this.autoApply = options.autoApply;
|
|
821
866
|
}
|
|
822
867
|
async propose(args) {
|
|
868
|
+
return this.prepare(args);
|
|
869
|
+
}
|
|
870
|
+
async submit(args) {
|
|
871
|
+
const proposal = await this.prepare(args, true);
|
|
872
|
+
return { ...proposal, state: proposal.result ? "applied" : "pending" };
|
|
873
|
+
}
|
|
874
|
+
async prepare(args, submitted = false) {
|
|
823
875
|
const botId = text(args.botId, "botId", 128);
|
|
824
876
|
const threadId = text(args.threadId, "threadId", 128);
|
|
825
877
|
const at = this.now();
|
|
@@ -848,7 +900,8 @@ export class RoutineRequestService {
|
|
|
848
900
|
createdAt: cardAt,
|
|
849
901
|
operation,
|
|
850
902
|
};
|
|
851
|
-
const
|
|
903
|
+
const definition = effectiveDefinition(operation, this.routines);
|
|
904
|
+
const timeZone = definition?.schedule.type === "cron" ? definition.schedule.timeZone : this.timeZone();
|
|
852
905
|
const copy = cardCopy(operation, this.routines, timeZone, cardAt);
|
|
853
906
|
const messageInput = {
|
|
854
907
|
role: "bot",
|
|
@@ -874,8 +927,14 @@ export class RoutineRequestService {
|
|
|
874
927
|
if (args.canCommit && !args.canCommit()) {
|
|
875
928
|
throw new RoutineRequestError("The requesting turn ended before this proposal could be saved", 401);
|
|
876
929
|
}
|
|
930
|
+
// Resolve the current source-thread grant after the asynchronous probe.
|
|
931
|
+
const automatic = submitted && this.autoApply?.(botId, threadId) === true;
|
|
932
|
+
if (automatic) {
|
|
933
|
+
messageInput.card.options = [];
|
|
934
|
+
messageInput.card.dismissed = true;
|
|
935
|
+
}
|
|
877
936
|
const message = this.store.appendMessage(threadId, messageInput);
|
|
878
|
-
|
|
937
|
+
const proposal = {
|
|
879
938
|
requestId,
|
|
880
939
|
messageId: message.id,
|
|
881
940
|
title: copy.title,
|
|
@@ -884,6 +943,30 @@ export class RoutineRequestService {
|
|
|
884
943
|
nextRunAt: copy.nextRunAt,
|
|
885
944
|
timeZone,
|
|
886
945
|
};
|
|
946
|
+
if (!automatic)
|
|
947
|
+
return proposal;
|
|
948
|
+
// Persist a hidden receipt first, then use the existing validated,
|
|
949
|
+
// idempotent commit path without exposing a pending confirmation.
|
|
950
|
+
let result;
|
|
951
|
+
try {
|
|
952
|
+
result = this.resolve({ botId, threadId, requestId, behavior: "allow" });
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
result = { claimed: true, state: "invalid", error: error instanceof Error ? error.message : String(error), status: error instanceof RoutineRequestError ? error.status : 400 };
|
|
956
|
+
}
|
|
957
|
+
if (result.state === "applied")
|
|
958
|
+
return { ...proposal, result };
|
|
959
|
+
// The scheduler commit can succeed even if settling its transcript
|
|
960
|
+
// fails. Report that exact result; a retry only finishes the receipt.
|
|
961
|
+
const receipt = this.routines.routineRequestReceipt(requestId);
|
|
962
|
+
if (receipt && receipt.botId === botId && receipt.threadId === threadId && receipt.messageId === message.id &&
|
|
963
|
+
receipt.fingerprintVersion === ROUTINE_REQUEST_FINGERPRINT_VERSION && receipt.fingerprint === routineRequestFingerprint(payload, message.id)) {
|
|
964
|
+
return { ...proposal, result: {
|
|
965
|
+
claimed: true, state: "applied", action: receipt.action, resultId: receipt.resultId,
|
|
966
|
+
settlementPending: true, message: "Routine change applied. Recording the operation receipt could not finish; the change will not be applied again.",
|
|
967
|
+
} };
|
|
968
|
+
}
|
|
969
|
+
throw new RoutineRequestError(result.state === "invalid" ? result.error : "The routine change could not be applied", result.state === "invalid" ? result.status : 409);
|
|
887
970
|
}
|
|
888
971
|
async requireCloudReadiness(operation) {
|
|
889
972
|
if (!this.cloudReady || operation.action === "pause" || operation.action === "delete")
|
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { DATA_DIR } from "./config.js";
|
|
6
6
|
import { writeFileAtomic } from "./atomic.js";
|
|
7
7
|
import { redactSecretsInText } from "./redact.js";
|
|
8
|
+
import { normalizeCronSchedule, nextCronRuns } from "../shared/routine-schedule.js";
|
|
8
9
|
const persistedSourceThreadId = z.string().trim().min(1).optional().catch(undefined);
|
|
9
10
|
const webhookRunReceiptSchema = z.object({
|
|
10
11
|
webhookId: z.string().min(1).max(200),
|
|
@@ -96,6 +97,8 @@ function loadAttachments(value) {
|
|
|
96
97
|
function cloneSchedule(schedule) {
|
|
97
98
|
if (schedule.type === "once")
|
|
98
99
|
return { type: "once", at: schedule.at };
|
|
100
|
+
if (schedule.type === "cron")
|
|
101
|
+
return { ...schedule };
|
|
99
102
|
if (schedule.type === "interval") {
|
|
100
103
|
return {
|
|
101
104
|
type: "interval",
|
|
@@ -236,7 +239,12 @@ function cleanIntervalEndsAt(value, anchorAt) {
|
|
|
236
239
|
function finishedOrder(run) {
|
|
237
240
|
return run.finishedAt ?? run.createdAt;
|
|
238
241
|
}
|
|
239
|
-
|
|
242
|
+
export class RoutineScheduleError extends Error {
|
|
243
|
+
status = 400;
|
|
244
|
+
}
|
|
245
|
+
function parseSchedule(schedule, after) {
|
|
246
|
+
if (schedule?.type === "cron")
|
|
247
|
+
return normalizeCronSchedule(schedule, after);
|
|
240
248
|
if (schedule?.type === "once") {
|
|
241
249
|
const at = Number(schedule.at);
|
|
242
250
|
if (!Number.isFinite(at))
|
|
@@ -274,9 +282,17 @@ function cleanSchedule(schedule) {
|
|
|
274
282
|
}
|
|
275
283
|
throw new Error("Choose a supported schedule");
|
|
276
284
|
}
|
|
277
|
-
function
|
|
285
|
+
function cleanSchedule(schedule, after) {
|
|
286
|
+
try {
|
|
287
|
+
return parseSchedule(schedule, after);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
throw new RoutineScheduleError(error.message);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function loadSchedule(value, after) {
|
|
278
294
|
try {
|
|
279
|
-
return cleanSchedule(value);
|
|
295
|
+
return cleanSchedule(value, after);
|
|
280
296
|
}
|
|
281
297
|
catch {
|
|
282
298
|
return null;
|
|
@@ -313,10 +329,12 @@ function nextAlignedInterval(schedule, after) {
|
|
|
313
329
|
const candidate = schedule.anchorAt + (intervalsElapsed + 1) * intervalMs;
|
|
314
330
|
return Number.isSafeInteger(candidate) && candidate <= MAX_DATE_MS ? candidate : null;
|
|
315
331
|
}
|
|
316
|
-
/** Next
|
|
332
|
+
/** Next occurrence strictly after `after`: cron uses its saved zone, daily uses the host zone. */
|
|
317
333
|
export function nextOccurrence(schedule, after) {
|
|
318
334
|
if (schedule.type === "once")
|
|
319
335
|
return schedule.at > after ? schedule.at : null;
|
|
336
|
+
if (schedule.type === "cron")
|
|
337
|
+
return nextCronRuns(schedule, after, 1)[0] ?? null;
|
|
320
338
|
if (schedule.type === "interval") {
|
|
321
339
|
const intervalMs = schedule.everyMinutes * 60_000;
|
|
322
340
|
let candidate = nextAlignedInterval(schedule, after);
|
|
@@ -378,7 +396,7 @@ function mergeScheduleUpdate(current, incoming) {
|
|
|
378
396
|
}
|
|
379
397
|
return merged;
|
|
380
398
|
}
|
|
381
|
-
function sanitizeInput(input) {
|
|
399
|
+
function sanitizeInput(input, after) {
|
|
382
400
|
const name = String(input.name ?? "").trim().slice(0, 80);
|
|
383
401
|
const prompt = String(input.prompt ?? "").trim().slice(0, 20_000);
|
|
384
402
|
const botId = String(input.botId ?? "").trim();
|
|
@@ -420,7 +438,7 @@ function sanitizeInput(input) {
|
|
|
420
438
|
groupId: target === "room-goal" ? groupId : undefined,
|
|
421
439
|
runOn,
|
|
422
440
|
enabled: input.enabled !== false,
|
|
423
|
-
schedule: cleanSchedule(input.schedule),
|
|
441
|
+
schedule: cleanSchedule(input.schedule, after),
|
|
424
442
|
durationMinutes: Math.min(240, Math.max(5, Math.round(Number(input.durationMinutes) || 30))),
|
|
425
443
|
...(timeoutMinutes === undefined ? {} : { timeoutMinutes }),
|
|
426
444
|
attachments,
|
|
@@ -445,7 +463,7 @@ export class RoutineManager {
|
|
|
445
463
|
const disk = JSON.parse(readFileSync(this.file, "utf8"));
|
|
446
464
|
this.routines = Array.isArray(disk.routines)
|
|
447
465
|
? disk.routines.flatMap((routine) => {
|
|
448
|
-
const schedule = loadSchedule(routine.schedule);
|
|
466
|
+
const schedule = loadSchedule(routine.schedule, this.now());
|
|
449
467
|
if (!schedule)
|
|
450
468
|
return [];
|
|
451
469
|
const target = loadTarget(routine.target);
|
|
@@ -630,10 +648,10 @@ export class RoutineManager {
|
|
|
630
648
|
throw new Error("This routine request was already applied");
|
|
631
649
|
}
|
|
632
650
|
}
|
|
633
|
-
const
|
|
651
|
+
const at = this.now();
|
|
652
|
+
const clean = sanitizeInput(input, at);
|
|
634
653
|
if (this.targetState(clean) === "missing")
|
|
635
654
|
throw new Error(this.missingTargetMessage(clean.target));
|
|
636
|
-
const at = this.now();
|
|
637
655
|
const nextRunAt = clean.enabled ? this.initialOccurrence(clean.schedule, at) : null;
|
|
638
656
|
if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
|
|
639
657
|
throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
|
|
@@ -682,7 +700,7 @@ export class RoutineManager {
|
|
|
682
700
|
timeoutMinutes: Object.hasOwn(patch, "timeoutMinutes") ? patch.timeoutMinutes : routine.timeoutMinutes,
|
|
683
701
|
attachments: patch.attachments ?? routine.attachments,
|
|
684
702
|
continuity: patch.continuity ?? routine.continuity,
|
|
685
|
-
});
|
|
703
|
+
}, now);
|
|
686
704
|
if (this.targetState(clean) === "missing")
|
|
687
705
|
throw new Error(this.missingTargetMessage(clean.target));
|
|
688
706
|
const scheduleChanged = JSON.stringify(clean.schedule) !== JSON.stringify(routine.schedule);
|
|
@@ -1058,9 +1076,9 @@ export class RoutineManager {
|
|
|
1058
1076
|
const scheduledFor = routine.schedule.type === "interval" && late <= CATCH_UP_MS
|
|
1059
1077
|
? latestIntervalOccurrence(routine.schedule, now) ?? pendingAt
|
|
1060
1078
|
: pendingAt;
|
|
1061
|
-
//
|
|
1062
|
-
// copies
|
|
1063
|
-
const overlapping = routine.schedule.type === "interval" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
|
|
1079
|
+
// Frequent recurring work must not build an unbounded queue of stale
|
|
1080
|
+
// copies. Elapsed intervals keep their phase; cron keeps its calendar.
|
|
1081
|
+
const overlapping = (routine.schedule.type === "interval" || routine.schedule.type === "cron") && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
|
|
1064
1082
|
if (!overlapping) {
|
|
1065
1083
|
const run = this.newRun(routine, scheduledFor, false, allocations);
|
|
1066
1084
|
if (late > CATCH_UP_MS) {
|