openmausbot 0.1.67 → 0.1.69
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-BwpNqgCm.js → index-BmHupgVJ.js} +1 -1
- package/dist/assets/index-COp5lMnv.js +279 -0
- package/dist/assets/index-Jk7TIeyW.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +40 -2
- package/dist-server/drivers/agents-proxy.js +112 -7
- package/dist-server/enterprise/server/index.js +14587 -0
- package/dist-server/index.js +1732 -681
- package/dist-server/openmausbot.js +939 -381
- package/dist-server/pair-cli.js +939 -381
- package/dist-server/server/avatar-image.js +86 -34
- package/dist-server/server/bot-package.js +60 -14
- package/dist-server/server/brand.js +7 -0
- package/dist-server/server/cli.js +23 -1
- package/dist-server/server/config.js +30 -3
- package/dist-server/server/delegations.js +56 -11
- package/dist-server/server/drivers/acp/qwen.js +142 -5
- package/dist-server/server/drivers/agents-proxy.js +135 -8
- package/dist-server/server/drivers/codex-device-auth.js +102 -3
- package/dist-server/server/drivers/codex-identity.js +115 -0
- package/dist-server/server/drivers/codex.js +6 -0
- package/dist-server/server/harness/registry.js +2 -0
- package/dist-server/server/index.js +220 -65
- package/dist-server/server/package-export.js +7 -0
- package/dist-server/server/provider-auth-sessions.js +24 -0
- package/dist-server/server/routine-requests.js +270 -25
- package/dist-server/server/routines.js +215 -21
- package/dist-server/server/service-cli.js +63 -0
- package/dist-server/server/service-unit.js +129 -0
- package/dist-server/server/steer-queue.js +30 -1
- package/dist-server/server/store.js +30 -4
- package/dist-server/server/system-prompt.js +1 -0
- package/dist-server/server/turn-dispatch-guard.js +6 -0
- package/dist-server/shared/image-generation.js +28 -0
- package/dist-server/vps-container-mcp.js +40 -2
- package/enterprise/LICENSE +32 -0
- package/enterprise/README.md +89 -0
- package/enterprise/server/index.js +14587 -0
- package/package.json +2 -1
- package/dist/assets/index-A3CBTxwl.css +0 -1
- package/dist/assets/index-CDuyoVPJ.js +0 -278
|
@@ -12,6 +12,9 @@ function routineRequestOwnerKey(owner) {
|
|
|
12
12
|
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
13
13
|
const CATCH_UP_MS = 12 * 60 * 60_000;
|
|
14
14
|
const MAX_DATE_MS = 8_640_000_000_000_000;
|
|
15
|
+
const LOCAL_DAY_MS = 24 * 60 * 60_000;
|
|
16
|
+
const INTERVAL_RESTRICTION_SEARCH_MS = 9 * LOCAL_DAY_MS;
|
|
17
|
+
const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
15
18
|
const MAX_RUNS = 2_000;
|
|
16
19
|
const MAX_ATTACHMENTS = 50;
|
|
17
20
|
const attachmentSchema = z.object({
|
|
@@ -86,7 +89,14 @@ function cloneSchedule(schedule) {
|
|
|
86
89
|
if (schedule.type === "once")
|
|
87
90
|
return { type: "once", at: schedule.at };
|
|
88
91
|
if (schedule.type === "interval") {
|
|
89
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
type: "interval",
|
|
94
|
+
everyMinutes: schedule.everyMinutes,
|
|
95
|
+
anchorAt: schedule.anchorAt,
|
|
96
|
+
...(schedule.weekdays ? { weekdays: [...schedule.weekdays] } : {}),
|
|
97
|
+
...(schedule.window ? { window: { ...schedule.window } } : {}),
|
|
98
|
+
...(schedule.endsAt === undefined ? {} : { endsAt: schedule.endsAt }),
|
|
99
|
+
};
|
|
90
100
|
}
|
|
91
101
|
return { type: "daily", time: schedule.time, weekdays: [...schedule.weekdays] };
|
|
92
102
|
}
|
|
@@ -164,6 +174,57 @@ function composeExecutionPrompt(prompt, attachments, carry) {
|
|
|
164
174
|
}
|
|
165
175
|
return parts.filter(Boolean).join("\n\n");
|
|
166
176
|
}
|
|
177
|
+
function cleanIntervalWeekdays(value) {
|
|
178
|
+
if (value == null)
|
|
179
|
+
return undefined;
|
|
180
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > ALL_DAYS.length) {
|
|
181
|
+
throw new Error("Choose at least one valid interval day");
|
|
182
|
+
}
|
|
183
|
+
if (value.some((day) => typeof day !== "number" || !Number.isInteger(day) || day < 0 || day > 6)) {
|
|
184
|
+
throw new Error("Interval days must be whole numbers from 0 to 6");
|
|
185
|
+
}
|
|
186
|
+
if (new Set(value).size !== value.length)
|
|
187
|
+
throw new Error("Choose each interval day only once");
|
|
188
|
+
const weekdays = [...value].sort((a, b) => a - b);
|
|
189
|
+
return weekdays.length === ALL_DAYS.length ? undefined : weekdays;
|
|
190
|
+
}
|
|
191
|
+
function clockMinutes(value) {
|
|
192
|
+
const match = CLOCK_TIME.exec(value);
|
|
193
|
+
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
|
194
|
+
}
|
|
195
|
+
function cleanIntervalWindow(value, everyMinutes) {
|
|
196
|
+
if (value == null)
|
|
197
|
+
return undefined;
|
|
198
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
199
|
+
throw new Error("Choose a valid interval time window");
|
|
200
|
+
}
|
|
201
|
+
const candidate = value;
|
|
202
|
+
const start = typeof candidate.start === "string" ? candidate.start : "";
|
|
203
|
+
const end = typeof candidate.end === "string" ? candidate.end : "";
|
|
204
|
+
const startMinutes = clockMinutes(start);
|
|
205
|
+
const endMinutes = clockMinutes(end);
|
|
206
|
+
if (startMinutes == null || endMinutes == null) {
|
|
207
|
+
throw new Error("Interval window times must use HH:MM");
|
|
208
|
+
}
|
|
209
|
+
if (startMinutes >= endMinutes) {
|
|
210
|
+
throw new Error("Interval window must start before it ends on the same day");
|
|
211
|
+
}
|
|
212
|
+
if (endMinutes - startMinutes < everyMinutes) {
|
|
213
|
+
throw new Error("Interval window must be at least as long as the interval");
|
|
214
|
+
}
|
|
215
|
+
return { start, end };
|
|
216
|
+
}
|
|
217
|
+
function cleanIntervalEndsAt(value, anchorAt) {
|
|
218
|
+
if (value == null)
|
|
219
|
+
return undefined;
|
|
220
|
+
if (typeof value !== "number" ||
|
|
221
|
+
!Number.isSafeInteger(value) ||
|
|
222
|
+
value < anchorAt ||
|
|
223
|
+
value > MAX_DATE_MS) {
|
|
224
|
+
throw new Error("Choose a valid interval end time after its start");
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
167
228
|
function finishedOrder(run) {
|
|
168
229
|
return run.finishedAt ?? run.createdAt;
|
|
169
230
|
}
|
|
@@ -191,7 +252,17 @@ function cleanSchedule(schedule) {
|
|
|
191
252
|
anchorAt > MAX_DATE_MS) {
|
|
192
253
|
throw new Error("Choose a valid interval start time");
|
|
193
254
|
}
|
|
194
|
-
|
|
255
|
+
const weekdays = cleanIntervalWeekdays(schedule.weekdays);
|
|
256
|
+
const window = cleanIntervalWindow(schedule.window, everyMinutes);
|
|
257
|
+
const endsAt = cleanIntervalEndsAt(schedule.endsAt, anchorAt);
|
|
258
|
+
return {
|
|
259
|
+
type: "interval",
|
|
260
|
+
everyMinutes,
|
|
261
|
+
anchorAt,
|
|
262
|
+
...(weekdays ? { weekdays } : {}),
|
|
263
|
+
...(window ? { window } : {}),
|
|
264
|
+
...(endsAt === undefined ? {} : { endsAt }),
|
|
265
|
+
};
|
|
195
266
|
}
|
|
196
267
|
throw new Error("Choose a supported schedule");
|
|
197
268
|
}
|
|
@@ -203,17 +274,56 @@ function loadSchedule(value) {
|
|
|
203
274
|
return null;
|
|
204
275
|
}
|
|
205
276
|
}
|
|
277
|
+
function intervalHasRestrictions(schedule) {
|
|
278
|
+
return schedule.weekdays !== undefined || schedule.window !== undefined || schedule.endsAt !== undefined;
|
|
279
|
+
}
|
|
280
|
+
function intervalAllowsOccurrence(schedule, at) {
|
|
281
|
+
if (schedule.endsAt !== undefined && at > schedule.endsAt)
|
|
282
|
+
return false;
|
|
283
|
+
const date = new Date(at);
|
|
284
|
+
if (schedule.weekdays && !schedule.weekdays.includes(date.getDay()))
|
|
285
|
+
return false;
|
|
286
|
+
if (schedule.window) {
|
|
287
|
+
const minute = date.getHours() * 60 + date.getMinutes();
|
|
288
|
+
const start = clockMinutes(schedule.window.start);
|
|
289
|
+
const end = clockMinutes(schedule.window.end);
|
|
290
|
+
if (minute < start || minute >= end)
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
function isSameLocalDay(left, right) {
|
|
296
|
+
const a = new Date(left);
|
|
297
|
+
const b = new Date(right);
|
|
298
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
299
|
+
}
|
|
300
|
+
function nextAlignedInterval(schedule, after) {
|
|
301
|
+
if (schedule.anchorAt > after)
|
|
302
|
+
return schedule.anchorAt;
|
|
303
|
+
const intervalMs = schedule.everyMinutes * 60_000;
|
|
304
|
+
const intervalsElapsed = Math.floor((after - schedule.anchorAt) / intervalMs);
|
|
305
|
+
const candidate = schedule.anchorAt + (intervalsElapsed + 1) * intervalMs;
|
|
306
|
+
return Number.isSafeInteger(candidate) && candidate <= MAX_DATE_MS ? candidate : null;
|
|
307
|
+
}
|
|
206
308
|
/** Next wall-clock occurrence in this computer's timezone, strictly after `after`. */
|
|
207
309
|
export function nextOccurrence(schedule, after) {
|
|
208
310
|
if (schedule.type === "once")
|
|
209
311
|
return schedule.at > after ? schedule.at : null;
|
|
210
312
|
if (schedule.type === "interval") {
|
|
211
|
-
if (schedule.anchorAt > after)
|
|
212
|
-
return schedule.anchorAt;
|
|
213
313
|
const intervalMs = schedule.everyMinutes * 60_000;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
314
|
+
let candidate = nextAlignedInterval(schedule, after);
|
|
315
|
+
if (!intervalHasRestrictions(schedule))
|
|
316
|
+
return candidate;
|
|
317
|
+
const maxCandidates = Math.ceil(INTERVAL_RESTRICTION_SEARCH_MS / intervalMs) + 2;
|
|
318
|
+
for (let checked = 0; candidate !== null && checked < maxCandidates; checked++) {
|
|
319
|
+
if (schedule.endsAt !== undefined && candidate > schedule.endsAt)
|
|
320
|
+
return null;
|
|
321
|
+
if (intervalAllowsOccurrence(schedule, candidate))
|
|
322
|
+
return candidate;
|
|
323
|
+
const next = candidate + intervalMs;
|
|
324
|
+
candidate = Number.isSafeInteger(next) && next <= MAX_DATE_MS ? next : null;
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
217
327
|
}
|
|
218
328
|
const [hour, minute] = schedule.time.split(":").map(Number);
|
|
219
329
|
const weekdays = new Set(cleanDays(schedule.weekdays));
|
|
@@ -227,10 +337,38 @@ export function nextOccurrence(schedule, after) {
|
|
|
227
337
|
return null;
|
|
228
338
|
}
|
|
229
339
|
function latestIntervalOccurrence(schedule, at) {
|
|
230
|
-
if (schedule.anchorAt > at)
|
|
231
|
-
return null;
|
|
232
340
|
const intervalMs = schedule.everyMinutes * 60_000;
|
|
233
|
-
|
|
341
|
+
const ceiling = Math.min(at, schedule.endsAt ?? at);
|
|
342
|
+
if (schedule.anchorAt > ceiling)
|
|
343
|
+
return null;
|
|
344
|
+
let candidate = schedule.anchorAt + Math.floor((ceiling - schedule.anchorAt) / intervalMs) * intervalMs;
|
|
345
|
+
if (!intervalHasRestrictions(schedule))
|
|
346
|
+
return candidate;
|
|
347
|
+
const maxCandidates = Math.ceil(INTERVAL_RESTRICTION_SEARCH_MS / intervalMs) + 2;
|
|
348
|
+
for (let checked = 0; checked < maxCandidates; checked++) {
|
|
349
|
+
if (intervalAllowsOccurrence(schedule, candidate))
|
|
350
|
+
return candidate;
|
|
351
|
+
const previous = candidate - intervalMs;
|
|
352
|
+
if (!Number.isSafeInteger(previous) || previous < schedule.anchorAt)
|
|
353
|
+
return null;
|
|
354
|
+
candidate = previous;
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
function mergeScheduleUpdate(current, incoming) {
|
|
359
|
+
if (current.type !== "interval" || incoming.type !== "interval")
|
|
360
|
+
return incoming;
|
|
361
|
+
const merged = { ...incoming };
|
|
362
|
+
if (!Object.hasOwn(incoming, "weekdays") && current.weekdays) {
|
|
363
|
+
merged.weekdays = [...current.weekdays];
|
|
364
|
+
}
|
|
365
|
+
if (!Object.hasOwn(incoming, "window") && current.window) {
|
|
366
|
+
merged.window = { ...current.window };
|
|
367
|
+
}
|
|
368
|
+
if (!Object.hasOwn(incoming, "endsAt") && current.endsAt !== undefined) {
|
|
369
|
+
merged.endsAt = current.endsAt;
|
|
370
|
+
}
|
|
371
|
+
return merged;
|
|
234
372
|
}
|
|
235
373
|
function sanitizeInput(input) {
|
|
236
374
|
const name = String(input.name ?? "").trim().slice(0, 80);
|
|
@@ -449,6 +587,10 @@ export class RoutineManager {
|
|
|
449
587
|
isActiveThread(threadId) {
|
|
450
588
|
return this.runs.some((run) => run.threadId === threadId && ["running", "waiting"].includes(run.status));
|
|
451
589
|
}
|
|
590
|
+
runForThread(threadId) {
|
|
591
|
+
const run = this.runs.find((candidate) => candidate.threadId === threadId);
|
|
592
|
+
return run ? cloneRun(run) : null;
|
|
593
|
+
}
|
|
452
594
|
create(input, request) {
|
|
453
595
|
if (request) {
|
|
454
596
|
const receipt = this.matchingRoutineRequestReceipt(request);
|
|
@@ -463,13 +605,17 @@ export class RoutineManager {
|
|
|
463
605
|
if (this.targetState(clean) === "missing")
|
|
464
606
|
throw new Error(this.missingTargetMessage(clean.target));
|
|
465
607
|
const at = this.now();
|
|
608
|
+
const nextRunAt = clean.enabled ? this.initialOccurrence(clean.schedule, at) : null;
|
|
609
|
+
if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
|
|
610
|
+
throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
|
|
611
|
+
}
|
|
466
612
|
const routine = {
|
|
467
613
|
id: randomUUID(),
|
|
468
614
|
...clean,
|
|
469
615
|
// Only a confirmed chat card supplies `request`; the public calendar
|
|
470
616
|
// API cannot choose an arbitrary transcript as a reporting target.
|
|
471
617
|
sourceThreadId: request?.threadId,
|
|
472
|
-
nextRunAt
|
|
618
|
+
nextRunAt,
|
|
473
619
|
createdAt: at,
|
|
474
620
|
updatedAt: at,
|
|
475
621
|
};
|
|
@@ -501,7 +647,7 @@ export class RoutineManager {
|
|
|
501
647
|
groupId: Object.hasOwn(patch, "groupId") ? patch.groupId : routine.groupId,
|
|
502
648
|
runOn: patch.runOn ?? routine.runOn,
|
|
503
649
|
enabled: patch.enabled ?? routine.enabled,
|
|
504
|
-
schedule: patch.schedule
|
|
650
|
+
schedule: patch.schedule ? mergeScheduleUpdate(routine.schedule, patch.schedule) : routine.schedule,
|
|
505
651
|
durationMinutes: patch.durationMinutes ?? routine.durationMinutes,
|
|
506
652
|
timeoutMinutes: Object.hasOwn(patch, "timeoutMinutes") ? patch.timeoutMinutes : routine.timeoutMinutes,
|
|
507
653
|
attachments: patch.attachments ?? routine.attachments,
|
|
@@ -509,10 +655,19 @@ export class RoutineManager {
|
|
|
509
655
|
});
|
|
510
656
|
if (this.targetState(clean) === "missing")
|
|
511
657
|
throw new Error(this.missingTargetMessage(clean.target));
|
|
658
|
+
const scheduleChanged = JSON.stringify(clean.schedule) !== JSON.stringify(routine.schedule);
|
|
659
|
+
const enabledChanged = clean.enabled !== routine.enabled;
|
|
660
|
+
// Definition-only edits retain due work and offline catch-up.
|
|
661
|
+
const nextRunAt = !clean.enabled ? null : scheduleChanged || enabledChanged
|
|
662
|
+
? this.initialOccurrence(clean.schedule, now)
|
|
663
|
+
: routine.nextRunAt;
|
|
664
|
+
if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
|
|
665
|
+
throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
|
|
666
|
+
}
|
|
512
667
|
const cancelledRuns = [];
|
|
513
668
|
this.commitMutation(() => {
|
|
514
669
|
Object.assign(routine, clean, {
|
|
515
|
-
nextRunAt
|
|
670
|
+
nextRunAt,
|
|
516
671
|
// `updatedAt` doubles as the optimistic revision on durable routine
|
|
517
672
|
// confirmation cards. Keep it monotonic even for two writes in one ms.
|
|
518
673
|
updatedAt: Math.max(now, routine.updatedAt + 1),
|
|
@@ -861,6 +1016,10 @@ export class RoutineManager {
|
|
|
861
1016
|
routine.enabled = false;
|
|
862
1017
|
routine.updatedAt = Math.max(now, routine.updatedAt + 1);
|
|
863
1018
|
}
|
|
1019
|
+
else if (routine.schedule.type === "interval" && routine.nextRunAt === null) {
|
|
1020
|
+
routine.enabled = false;
|
|
1021
|
+
routine.updatedAt = Math.max(now, routine.updatedAt + 1);
|
|
1022
|
+
}
|
|
864
1023
|
this.emitRoutine(routine);
|
|
865
1024
|
changed = true;
|
|
866
1025
|
}
|
|
@@ -868,7 +1027,10 @@ export class RoutineManager {
|
|
|
868
1027
|
this.save();
|
|
869
1028
|
for (const missed of missedRuns)
|
|
870
1029
|
this.options.onRunFailed?.(missed);
|
|
871
|
-
|
|
1030
|
+
// Oldest queued requests have priority. New manual/webhook arrivals
|
|
1031
|
+
// must not continually overtake work that has already waited. Snapshot
|
|
1032
|
+
// the queue because each dispatch can asynchronously add/cancel work.
|
|
1033
|
+
for (const run of this.runs.slice()) {
|
|
872
1034
|
if (run.status !== "queued")
|
|
873
1035
|
continue;
|
|
874
1036
|
// A queued interval represents the latest useful check, not a backlog
|
|
@@ -887,6 +1049,15 @@ export class RoutineManager {
|
|
|
887
1049
|
this.save();
|
|
888
1050
|
this.emitRun(run);
|
|
889
1051
|
}
|
|
1052
|
+
const requiresCurrentDayOccurrence = definition.schedule.weekdays !== undefined || definition.schedule.window !== undefined;
|
|
1053
|
+
const mayDispatch = intervalAllowsOccurrence(definition.schedule, now) &&
|
|
1054
|
+
(!requiresCurrentDayOccurrence || (latest !== null && isSameLocalDay(latest, now)));
|
|
1055
|
+
if (!mayDispatch) {
|
|
1056
|
+
if (nextOccurrence(definition.schedule, now) === null) {
|
|
1057
|
+
this.missQueuedRun(run, "The routine ended before this scheduled run could start");
|
|
1058
|
+
}
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
890
1061
|
}
|
|
891
1062
|
const state = this.targetState(run);
|
|
892
1063
|
if (state === "busy")
|
|
@@ -949,9 +1120,19 @@ export class RoutineManager {
|
|
|
949
1120
|
// completion and private coordinator envelope are only intermediate
|
|
950
1121
|
// protocol, never the routine receipt's result.
|
|
951
1122
|
if (run.target === "room-goal" &&
|
|
952
|
-
(
|
|
1123
|
+
(
|
|
1124
|
+
// These outcomes ended the goal operation. Later room traffic is
|
|
1125
|
+
// not a resume of that run; only the goal lifecycle can change its
|
|
1126
|
+
// receipt. In-flight provider approvals have no goalStatus and
|
|
1127
|
+
// continue to resolve normally below.
|
|
1128
|
+
run.goalStatus === "needs-input" || run.goalStatus === "paused" ||
|
|
1129
|
+
event.type === "turn.completed" || (event.type === "item.completed" && event.itemType === "assistant_text")))
|
|
953
1130
|
return null;
|
|
954
|
-
if (event.type === "
|
|
1131
|
+
if (event.type === "turn.started") {
|
|
1132
|
+
run.status = "running";
|
|
1133
|
+
run.attention = undefined;
|
|
1134
|
+
}
|
|
1135
|
+
else if (event.type === "request.opened") {
|
|
955
1136
|
run.status = "waiting";
|
|
956
1137
|
run.attention = redactSecretsInText(event.summary).trim().slice(0, 500) || undefined;
|
|
957
1138
|
}
|
|
@@ -971,16 +1152,20 @@ export class RoutineManager {
|
|
|
971
1152
|
return null;
|
|
972
1153
|
}
|
|
973
1154
|
else if (event.type === "turn.completed") {
|
|
974
|
-
|
|
975
|
-
|
|
1155
|
+
if (event.cost != null)
|
|
1156
|
+
run.cost = (run.cost ?? 0) + event.cost;
|
|
1157
|
+
if (event.denials?.length)
|
|
1158
|
+
run.denials = [...new Set([...(run.denials ?? []), ...event.denials])];
|
|
976
1159
|
if (!event.ok) {
|
|
977
1160
|
this.failRun(run, event.stopReason ?? run.error ?? "The bot did not complete this run");
|
|
978
1161
|
queueMicrotask(() => void this.tick());
|
|
979
1162
|
return cloneRun(run);
|
|
980
1163
|
}
|
|
981
|
-
|
|
982
|
-
run.
|
|
983
|
-
run.
|
|
1164
|
+
const pending = this.options.hasPendingDelegations?.(event.threadId) === true;
|
|
1165
|
+
run.status = pending ? "waiting" : "completed";
|
|
1166
|
+
run.attention = pending ? "Waiting for delegated work to finish" : undefined;
|
|
1167
|
+
if (!pending)
|
|
1168
|
+
run.finishedAt = this.now();
|
|
984
1169
|
run.error = undefined;
|
|
985
1170
|
}
|
|
986
1171
|
else {
|
|
@@ -1044,6 +1229,15 @@ export class RoutineManager {
|
|
|
1044
1229
|
this.emitRun(run);
|
|
1045
1230
|
this.options.onRunFailed?.(cloneRun(run));
|
|
1046
1231
|
}
|
|
1232
|
+
missQueuedRun(run, message) {
|
|
1233
|
+
run.status = "missed";
|
|
1234
|
+
run.attention = undefined;
|
|
1235
|
+
run.error = redactSecretsInText(message).slice(0, 500);
|
|
1236
|
+
run.finishedAt = this.now();
|
|
1237
|
+
this.save();
|
|
1238
|
+
this.emitRun(run);
|
|
1239
|
+
this.options.onRunFailed?.(cloneRun(run));
|
|
1240
|
+
}
|
|
1047
1241
|
targetState(target) {
|
|
1048
1242
|
if (target.target === "room-goal") {
|
|
1049
1243
|
if (!target.groupId || !this.options.goalState)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The `openmausbot service` command, kept separate from cli.ts so it can be
|
|
2
|
+
// tested with explicit inputs: it renders the unit for this platform, writes
|
|
3
|
+
// it next to the data, and prints the commands that install it.
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { currentUser, launchdPlist, servicePlan, systemdUnit, unstableInstallWarning } from "./service-unit.js";
|
|
7
|
+
/** The `serve` arguments the service repeats, from the options given to `service install`. */
|
|
8
|
+
export function serviceServeArgs(input) {
|
|
9
|
+
const args = ["--port", String(input.port), "--data-dir", input.dataDir, "--no-pair"];
|
|
10
|
+
if (input.domain)
|
|
11
|
+
args.push("--domain", input.domain);
|
|
12
|
+
else if (input.tunnel)
|
|
13
|
+
args.push("--tunnel");
|
|
14
|
+
else if (input.tailscale)
|
|
15
|
+
args.push("--tailscale");
|
|
16
|
+
if (input.label)
|
|
17
|
+
args.push("--label", input.label);
|
|
18
|
+
return args;
|
|
19
|
+
}
|
|
20
|
+
export function runServiceCommand(input, io) {
|
|
21
|
+
const platform = input.platform ?? process.platform;
|
|
22
|
+
const home = input.home ?? homedir();
|
|
23
|
+
const plan = servicePlan(platform, input.dataDir, home);
|
|
24
|
+
if (!plan) {
|
|
25
|
+
io.error("services are written for Linux (systemd) and macOS (launchd); on Windows, use Task Scheduler to run `openmausbot serve` at startup");
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
if (input.action === "uninstall") {
|
|
29
|
+
io.log(`to stop and remove the service:`);
|
|
30
|
+
for (const line of plan.deactivate)
|
|
31
|
+
io.log(` ${line}`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const warning = unstableInstallWarning(input.script);
|
|
35
|
+
if (warning) {
|
|
36
|
+
io.error(warning);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const spec = {
|
|
40
|
+
node: input.node,
|
|
41
|
+
script: input.script,
|
|
42
|
+
serveArgs: serviceServeArgs(input),
|
|
43
|
+
dataDir: input.dataDir,
|
|
44
|
+
user: input.user ?? currentUser(),
|
|
45
|
+
home,
|
|
46
|
+
bindsLowPorts: Boolean(input.domain),
|
|
47
|
+
...(input.label ? { label: input.label } : {}),
|
|
48
|
+
};
|
|
49
|
+
const rendered = platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
|
|
50
|
+
mkdirSync(input.dataDir, { recursive: true, mode: 0o700 });
|
|
51
|
+
writeFileSync(plan.file, rendered, { mode: 0o644 });
|
|
52
|
+
io.log(`wrote ${plan.file}`);
|
|
53
|
+
io.log("");
|
|
54
|
+
io.log(platform === "darwin" ? "to install and start it (runs at login):" : "to install and start it (runs at boot, restarts if it stops):");
|
|
55
|
+
for (const line of plan.activate)
|
|
56
|
+
io.log(` ${line}`);
|
|
57
|
+
io.log("");
|
|
58
|
+
if (input.domain && platform === "linux")
|
|
59
|
+
io.log("the unit grants Caddy the capability for ports 80 and 443, so no setcap is needed under the service");
|
|
60
|
+
io.log(`logs: ${platform === "darwin" ? `${input.dataDir}/logs/service.log` : "journalctl -u openmausbot -f"}`);
|
|
61
|
+
io.log(`change options later by running \`openmausbot service install\` again with the new ones, then: ${platform === "darwin" ? plan.activate[1] : "sudo systemctl daemon-reload && sudo systemctl restart openmausbot"}`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// `openmausbot service install`: keep the server running across reboots.
|
|
2
|
+
// Renders a systemd unit (Linux) or a launchd agent (macOS) that runs the
|
|
3
|
+
// same `openmausbot serve …` the operator just used, and either installs it
|
|
4
|
+
// (when allowed to) or writes it next to the data and prints the two
|
|
5
|
+
// commands that install it. Pure rendering lives here so it is testable;
|
|
6
|
+
// the CLI decides where the file goes.
|
|
7
|
+
import { homedir, userInfo } from "node:os";
|
|
8
|
+
import { posix } from "node:path";
|
|
9
|
+
// Unit files describe a Linux or macOS machine, so their paths are POSIX
|
|
10
|
+
// whatever host renders them (the Windows CI runner included).
|
|
11
|
+
const { basename, dirname, join } = posix;
|
|
12
|
+
export const SYSTEMD_UNIT_NAME = "openmausbot.service";
|
|
13
|
+
export const LAUNCHD_LABEL = "com.openmausbot.serve";
|
|
14
|
+
function quoteSystemd(value) {
|
|
15
|
+
// systemd's ExecStart splits on whitespace and understands double quotes.
|
|
16
|
+
return /[\s"\\]/.test(value) ? `"${value.replace(/[\\"]/g, "\\$&")}"` : value;
|
|
17
|
+
}
|
|
18
|
+
function needsStripTypes(script) {
|
|
19
|
+
return script.endsWith(".ts");
|
|
20
|
+
}
|
|
21
|
+
export function serviceCommand(spec) {
|
|
22
|
+
return [spec.node, ...(needsStripTypes(spec.script) ? ["--experimental-strip-types"] : []), spec.script, "serve", ...spec.serveArgs];
|
|
23
|
+
}
|
|
24
|
+
export function systemdUnit(spec) {
|
|
25
|
+
const lines = [
|
|
26
|
+
"# Written by `openmausbot service install`. Re-run it to change the options.",
|
|
27
|
+
"[Unit]",
|
|
28
|
+
`Description=OpenMausBot${spec.label ? ` (${spec.label})` : ""}`,
|
|
29
|
+
"After=network-online.target",
|
|
30
|
+
"Wants=network-online.target",
|
|
31
|
+
"",
|
|
32
|
+
"[Service]",
|
|
33
|
+
"Type=simple",
|
|
34
|
+
`User=${spec.user}`,
|
|
35
|
+
`WorkingDirectory=${spec.home}`,
|
|
36
|
+
`Environment=HOME=${spec.home}`,
|
|
37
|
+
`Environment=OMB_DATA_DIR=${spec.dataDir}`,
|
|
38
|
+
`ExecStart=${serviceCommand(spec).map(quoteSystemd).join(" ")}`,
|
|
39
|
+
"Restart=always",
|
|
40
|
+
"RestartSec=3",
|
|
41
|
+
"KillMode=mixed",
|
|
42
|
+
"TimeoutStopSec=30",
|
|
43
|
+
];
|
|
44
|
+
if (spec.bindsLowPorts) {
|
|
45
|
+
lines.push("# serve --domain: Caddy binds ports 80 and 443 without running as root.");
|
|
46
|
+
lines.push("AmbientCapabilities=CAP_NET_BIND_SERVICE");
|
|
47
|
+
lines.push("CapabilityBoundingSet=CAP_NET_BIND_SERVICE");
|
|
48
|
+
}
|
|
49
|
+
lines.push("", "[Install]", "WantedBy=multi-user.target", "");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
function xml(value) {
|
|
53
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
54
|
+
}
|
|
55
|
+
export function launchdPlist(spec) {
|
|
56
|
+
const args = serviceCommand(spec).map((arg) => `\t\t<string>${xml(arg)}</string>`).join("\n");
|
|
57
|
+
return [
|
|
58
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
59
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
60
|
+
'<plist version="1.0">',
|
|
61
|
+
"<dict>",
|
|
62
|
+
"\t<key>Label</key>",
|
|
63
|
+
`\t<string>${LAUNCHD_LABEL}</string>`,
|
|
64
|
+
"\t<key>ProgramArguments</key>",
|
|
65
|
+
"\t<array>",
|
|
66
|
+
args,
|
|
67
|
+
"\t</array>",
|
|
68
|
+
"\t<key>EnvironmentVariables</key>",
|
|
69
|
+
"\t<dict>",
|
|
70
|
+
"\t\t<key>HOME</key>",
|
|
71
|
+
`\t\t<string>${xml(spec.home)}</string>`,
|
|
72
|
+
"\t\t<key>OMB_DATA_DIR</key>",
|
|
73
|
+
`\t\t<string>${xml(spec.dataDir)}</string>`,
|
|
74
|
+
"\t\t<key>PATH</key>",
|
|
75
|
+
"\t\t<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>",
|
|
76
|
+
"\t</dict>",
|
|
77
|
+
"\t<key>WorkingDirectory</key>",
|
|
78
|
+
`\t<string>${xml(spec.home)}</string>`,
|
|
79
|
+
"\t<key>RunAtLoad</key>",
|
|
80
|
+
"\t<true/>",
|
|
81
|
+
"\t<key>KeepAlive</key>",
|
|
82
|
+
"\t<true/>",
|
|
83
|
+
"\t<key>StandardOutPath</key>",
|
|
84
|
+
`\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
|
|
85
|
+
"\t<key>StandardErrorPath</key>",
|
|
86
|
+
`\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
|
|
87
|
+
"</dict>",
|
|
88
|
+
"</plist>",
|
|
89
|
+
"",
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
/** An `npx` cache path is pruned without notice; a service must not point at it. */
|
|
93
|
+
export function unstableInstallWarning(script) {
|
|
94
|
+
const normalized = script.replace(/\\/g, "/");
|
|
95
|
+
if (/\/_npx\//.test(normalized) || /\/\.npm\/_npx\//.test(normalized)) {
|
|
96
|
+
return `this command runs from an npx cache (${dirname(script)}), which npm may delete at any time. Install it permanently first (npm install -g openmausbot) and run \`openmausbot service install\` from that install.`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/** Where the rendered file goes and how to activate it, per platform. */
|
|
101
|
+
export function servicePlan(platform, dataDir, home = homedir()) {
|
|
102
|
+
if (platform === "linux") {
|
|
103
|
+
const installed = `/etc/systemd/system/${SYSTEMD_UNIT_NAME}`;
|
|
104
|
+
return {
|
|
105
|
+
file: join(dataDir, SYSTEMD_UNIT_NAME),
|
|
106
|
+
installed,
|
|
107
|
+
activate: [`sudo install -m 644 ${join(dataDir, SYSTEMD_UNIT_NAME)} ${installed}`, "sudo systemctl daemon-reload", `sudo systemctl enable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`],
|
|
108
|
+
deactivate: [`sudo systemctl disable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`, `sudo rm ${installed}`, "sudo systemctl daemon-reload"],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (platform === "darwin") {
|
|
112
|
+
const installed = join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
113
|
+
return {
|
|
114
|
+
file: join(dataDir, `${LAUNCHD_LABEL}.plist`),
|
|
115
|
+
installed,
|
|
116
|
+
activate: [`mkdir -p ${dirname(installed)} && cp ${join(dataDir, `${LAUNCHD_LABEL}.plist`)} ${installed}`, `launchctl bootstrap gui/$(id -u) ${installed}`],
|
|
117
|
+
deactivate: [`launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`, `rm ${installed}`],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
export function currentUser() {
|
|
123
|
+
try {
|
|
124
|
+
return userInfo().username;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return process.env.USER || process.env.USERNAME || "openmausbot";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -18,6 +18,30 @@
|
|
|
18
18
|
// the feature.
|
|
19
19
|
import { newId } from "./contracts.js";
|
|
20
20
|
const queues = new Map(); // threadId → waiting sends
|
|
21
|
+
const listeners = new Set();
|
|
22
|
+
const changed = () => {
|
|
23
|
+
for (const listener of listeners) {
|
|
24
|
+
try {
|
|
25
|
+
listener();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
console.warn("steer-queue: change listener failed");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** Public pending chips only: never expose provider prompts or reply context. */
|
|
33
|
+
export function queuedSteerSnapshot(ownsThread) {
|
|
34
|
+
return Object.fromEntries([...queues]
|
|
35
|
+
.filter(([threadId, entry]) => ownsThread(entry.botId, threadId))
|
|
36
|
+
.map(([threadId, entry]) => [threadId, entry.items.map((item) => ({
|
|
37
|
+
queueId: item.messageId, text: item.text, ...(item.reason ? { reason: item.reason } : {}),
|
|
38
|
+
}))]));
|
|
39
|
+
}
|
|
40
|
+
/** Publish changes synchronously so every client can restore/cancel the queue. */
|
|
41
|
+
export function onSteeredQueueChange(listener) {
|
|
42
|
+
listeners.add(listener);
|
|
43
|
+
return () => { listeners.delete(listener); };
|
|
44
|
+
}
|
|
21
45
|
/** Hold a mid-turn send off the transcript until drain. */
|
|
22
46
|
export function queueSteeredMessage(botId, threadId, text, options = {}) {
|
|
23
47
|
const id = newId();
|
|
@@ -32,8 +56,10 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
|
|
|
32
56
|
prompt: options.prompt ?? text,
|
|
33
57
|
replyToId: options.replyToId,
|
|
34
58
|
sendId: options.sendId,
|
|
59
|
+
reason: options.reason,
|
|
35
60
|
});
|
|
36
61
|
queues.set(threadId, entry);
|
|
62
|
+
changed();
|
|
37
63
|
return { id };
|
|
38
64
|
}
|
|
39
65
|
/** Drain every queue whose task is idle: append the held lines (leaf is now
|
|
@@ -52,6 +78,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
|
|
|
52
78
|
if (!bot) {
|
|
53
79
|
// the bot or task was deleted while messages waited
|
|
54
80
|
queues.delete(threadId);
|
|
81
|
+
changed();
|
|
55
82
|
continue;
|
|
56
83
|
}
|
|
57
84
|
if (bot.busy || isBlocked?.(entry.botId, threadId))
|
|
@@ -59,6 +86,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
|
|
|
59
86
|
// committed to draining: the entry leaves the map before anything runs,
|
|
60
87
|
// so a settle racing another settle can never fire the same queue twice
|
|
61
88
|
queues.delete(threadId);
|
|
89
|
+
changed();
|
|
62
90
|
const appended = [];
|
|
63
91
|
for (const item of entry.items) {
|
|
64
92
|
// queueId is the pending-chip identity from the 202; append still
|
|
@@ -89,7 +117,7 @@ export function queuedSteeredMessage(botId, threadId, sendId) {
|
|
|
89
117
|
if (!entry || entry.botId !== botId)
|
|
90
118
|
return null;
|
|
91
119
|
const item = entry.items.find((candidate) => candidate.sendId === sendId);
|
|
92
|
-
return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId } : null;
|
|
120
|
+
return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId, ...(item.reason ? { reason: item.reason } : {}) } : null;
|
|
93
121
|
}
|
|
94
122
|
/** Drop one waiting send owned by this bot so it never drains. The queue id
|
|
95
123
|
* is stable even if the bot switches away from the task while the request is
|
|
@@ -106,6 +134,7 @@ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
|
|
|
106
134
|
queues.delete(threadId);
|
|
107
135
|
else
|
|
108
136
|
queues.set(threadId, { botId: entry.botId, items });
|
|
137
|
+
changed();
|
|
109
138
|
return true;
|
|
110
139
|
}
|
|
111
140
|
return false;
|