loopengine 0.1.92 → 0.1.94
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/README.md +14 -5
- package/dist/core/agent-config.d.ts +19 -7
- package/dist/core/email-senders.d.ts +26 -0
- package/dist/core/email-senders.js +108 -0
- package/dist/core/http-notifier.js +6 -6
- package/dist/core/http-notify-triggers/email.d.ts +14 -8
- package/dist/core/http-notify-triggers/email.js +70 -28
- package/dist/core/notifier-file.d.ts +11 -3
- package/dist/core/notifier-file.js +37 -5
- package/dist/core/scheduler.d.ts +22 -0
- package/dist/core/scheduler.js +111 -12
- package/dist/core/system-agents/templates.d.ts +43 -0
- package/dist/core/system-agents/templates.js +290 -48
- package/dist/core/system-tools/schedule.js +16 -6
- package/dist/core/time-zone.d.ts +40 -0
- package/dist/core/time-zone.js +138 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/web/agents-config-page.js +49 -11
- package/dist/web/global-config-page.js +65 -0
- package/dist/web/global-config.d.ts +24 -0
- package/dist/web/global-config.js +30 -0
- package/dist/web/notifier-admin.js +11 -2
- package/dist/web/templates-admin.d.ts +82 -7
- package/dist/web/templates-admin.js +207 -16
- package/dist/web/templates-page.js +31 -6
- package/package.json +5 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The workspace's time zone: what "9:00" means in a schedule, a
|
|
2
|
+
// template's start time and the console's date fields. Set from the
|
|
3
|
+
// console's Settings (LOOPENGINE_TIME_ZONE, an IANA name such as
|
|
4
|
+
// "America/New_York"); unset, it's the server's own, which on a hosted
|
|
5
|
+
// workspace is UTC.
|
|
6
|
+
//
|
|
7
|
+
// Times are worked out with Intl alone, no time zone data of our own:
|
|
8
|
+
// a wall-clock time is turned into an instant by guessing its offset
|
|
9
|
+
// and correcting it twice, which settles on the right answer either side
|
|
10
|
+
// of a daylight-saving change. A time the clocks skip (02:30 on the
|
|
11
|
+
// morning they spring forward) lands an hour later; a time that happens
|
|
12
|
+
// twice (the hour they fall back) is the first of the two.
|
|
13
|
+
export const TIME_ZONE_ENV = 'LOOPENGINE_TIME_ZONE';
|
|
14
|
+
const formatters = new Map();
|
|
15
|
+
function formatter(timeZone) {
|
|
16
|
+
let f = formatters.get(timeZone);
|
|
17
|
+
if (!f) {
|
|
18
|
+
f = new Intl.DateTimeFormat('en-US', {
|
|
19
|
+
timeZone,
|
|
20
|
+
hourCycle: 'h23',
|
|
21
|
+
year: 'numeric',
|
|
22
|
+
month: 'numeric',
|
|
23
|
+
day: 'numeric',
|
|
24
|
+
hour: 'numeric',
|
|
25
|
+
minute: 'numeric',
|
|
26
|
+
second: 'numeric',
|
|
27
|
+
weekday: 'short',
|
|
28
|
+
});
|
|
29
|
+
formatters.set(timeZone, f);
|
|
30
|
+
}
|
|
31
|
+
return f;
|
|
32
|
+
}
|
|
33
|
+
export function isValidTimeZone(timeZone) {
|
|
34
|
+
if (typeof timeZone !== 'string' || !timeZone.trim())
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
new Intl.DateTimeFormat('en-US', { timeZone });
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The server's own time zone. */
|
|
45
|
+
export function serverTimeZone() {
|
|
46
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
47
|
+
}
|
|
48
|
+
/** The workspace's time zone: LOOPENGINE_TIME_ZONE when it's a real one,
|
|
49
|
+
* else the server's. */
|
|
50
|
+
export function workspaceTimeZone() {
|
|
51
|
+
const configured = process.env[TIME_ZONE_ENV]?.trim();
|
|
52
|
+
return configured && isValidTimeZone(configured) ? configured : serverTimeZone();
|
|
53
|
+
}
|
|
54
|
+
/** Every time zone this server knows, for the console's picker. */
|
|
55
|
+
export function listTimeZones() {
|
|
56
|
+
const supported = Intl.supportedValuesOf?.('timeZone') ?? [];
|
|
57
|
+
return supported.includes('UTC') ? supported : ['UTC', ...supported];
|
|
58
|
+
}
|
|
59
|
+
const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
60
|
+
/** The wall-clock time (and weekday, 0 = Sunday) at `ms` in `timeZone`. */
|
|
61
|
+
export function wallTimeAt(ms, timeZone) {
|
|
62
|
+
const parts = {};
|
|
63
|
+
for (const p of formatter(timeZone).formatToParts(new Date(ms)))
|
|
64
|
+
parts[p.type] = p.value;
|
|
65
|
+
return {
|
|
66
|
+
year: Number(parts.year),
|
|
67
|
+
month: Number(parts.month),
|
|
68
|
+
day: Number(parts.day),
|
|
69
|
+
hour: Number(parts.hour),
|
|
70
|
+
minute: Number(parts.minute),
|
|
71
|
+
second: Number(parts.second),
|
|
72
|
+
weekday: WEEKDAY_INDEX[parts.weekday] ?? 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function asUtcMs(t) {
|
|
76
|
+
return Date.UTC(t.year, t.month - 1, t.day, t.hour, t.minute, t.second);
|
|
77
|
+
}
|
|
78
|
+
/** How far ahead of UTC `timeZone` is at `ms`, in ms. */
|
|
79
|
+
function offsetAt(ms, timeZone) {
|
|
80
|
+
return asUtcMs(wallTimeAt(Math.floor(ms / 1000) * 1000, timeZone)) - Math.floor(ms / 1000) * 1000;
|
|
81
|
+
}
|
|
82
|
+
/** The instant a wall-clock time in `timeZone` happens. Fields may
|
|
83
|
+
* overflow (day 32, hour 25), as with Date.UTC. */
|
|
84
|
+
export function instantOf(t, timeZone) {
|
|
85
|
+
const naive = asUtcMs(t);
|
|
86
|
+
const first = naive - offsetAt(naive, timeZone);
|
|
87
|
+
const second = naive - offsetAt(first, timeZone);
|
|
88
|
+
// Either side of a change the two guesses differ; the earlier one is
|
|
89
|
+
// right for a repeated hour, the later one for a skipped hour.
|
|
90
|
+
if (first === second)
|
|
91
|
+
return first;
|
|
92
|
+
return offsetAt(Math.min(first, second), timeZone) === naive - Math.min(first, second) ? Math.min(first, second) : Math.max(first, second);
|
|
93
|
+
}
|
|
94
|
+
const LOCAL_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/;
|
|
95
|
+
/** An ISO 8601 timestamp as ms. One with a "Z" or an offset is exact;
|
|
96
|
+
* one without ("2026-11-02T09:00") is a wall-clock time in `timeZone`.
|
|
97
|
+
* NaN when it isn't a timestamp. */
|
|
98
|
+
export function parseTimestamp(value, timeZone) {
|
|
99
|
+
const local = LOCAL_TIMESTAMP.exec(value.trim());
|
|
100
|
+
if (!local)
|
|
101
|
+
return Date.parse(value);
|
|
102
|
+
const [, y, mo, d, h, mi, s] = local;
|
|
103
|
+
const t = { year: Number(y), month: Number(mo), day: Number(d), hour: Number(h), minute: Number(mi), second: Number(s ?? 0) };
|
|
104
|
+
if (t.month < 1 || t.month > 12 || t.day < 1 || t.day > 31 || t.hour > 23 || t.minute > 59 || t.second > 59)
|
|
105
|
+
return Number.NaN;
|
|
106
|
+
return instantOf(t, timeZone);
|
|
107
|
+
}
|
|
108
|
+
const TIME_OF_DAY = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
109
|
+
/** "09:00" as [hours, minutes], or undefined when it isn't a time. */
|
|
110
|
+
export function parseTimeOfDay(value) {
|
|
111
|
+
const match = TIME_OF_DAY.exec(value.trim());
|
|
112
|
+
return match ? [Number(match[1]), Number(match[2])] : undefined;
|
|
113
|
+
}
|
|
114
|
+
/** The next time it's `at` ("09:00") in `timeZone`, strictly after
|
|
115
|
+
* `nowMs`, on `weekday` (0 = Sunday) when given. */
|
|
116
|
+
export function nextTimeOfDay(at, nowMs, timeZone, weekday) {
|
|
117
|
+
const today = wallTimeAt(nowMs, timeZone);
|
|
118
|
+
const ahead = weekday === undefined ? 0 : (weekday - today.weekday + 7) % 7;
|
|
119
|
+
const step = weekday === undefined ? 1 : 7;
|
|
120
|
+
for (let days = ahead;; days += step) {
|
|
121
|
+
const ms = instantOf({ year: today.year, month: today.month, day: today.day + days, hour: at[0], minute: at[1], second: 0 }, timeZone);
|
|
122
|
+
if (ms > nowMs)
|
|
123
|
+
return ms;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** `ms` moved by whole calendar days in `timeZone`, keeping its time of
|
|
127
|
+
* day across a daylight-saving change. */
|
|
128
|
+
export function addCalendarDays(ms, days, timeZone) {
|
|
129
|
+
const t = wallTimeAt(ms, timeZone);
|
|
130
|
+
return instantOf({ ...t, day: t.day + days }, timeZone);
|
|
131
|
+
}
|
|
132
|
+
/** `ms` as "2026-11-02 09:00" in `timeZone`, for tool results the model
|
|
133
|
+
* reads back to a person. */
|
|
134
|
+
export function formatWallTime(ms, timeZone) {
|
|
135
|
+
const t = wallTimeAt(ms, timeZone);
|
|
136
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
137
|
+
return `${t.year}-${pad(t.month)}-${pad(t.day)} ${pad(t.hour)}:${pad(t.minute)}`;
|
|
138
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
|
|
|
10
10
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile, type MessagingApprover } from '#core/notifier-file.js';
|
|
11
11
|
export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
|
|
12
12
|
export { describeNotifier } from '#core/http-notifier.js';
|
|
13
|
-
export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
|
|
13
|
+
export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, moveScheduledTasksToTimeZone, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
|
|
14
14
|
export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
|
|
15
15
|
export { firstSighting, handleInboundMessage, handleMessagingAction, settleThreadChoice, isPlainApproval, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
|
|
16
16
|
export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
|
|
@@ -74,8 +74,8 @@ export { interviewPageHtml } from './web/interview-page.js';
|
|
|
74
74
|
export { InterviewError, answerInterview, createFromCandidate, createAgentFromCandidate, describeCandidate, deleteInterview, finishInterview, getInterview, interviewTools, listInterviews, pickAgentModel, pickInterviewModel, previewCandidate, startInterview, setInterviewProject, setupChecklist, type Interview, type InterviewAgentSource, type CandidatePreview, type CreatedAgent, type ChecklistItem, type CreateDeps, } from './web/interview-admin.js';
|
|
75
75
|
export { runInterviewTurn, rankCandidates, validateCandidate, INTERVIEWER_PROMPT, type AgentCandidate, type InterviewStage } from './core/system-agents/interviewer.js';
|
|
76
76
|
export { templatesPageHtml } from './web/templates-page.js';
|
|
77
|
-
export { TemplateError, createFromTemplate, listTemplates, previewTemplate, type TemplateSummary } from './web/templates-admin.js';
|
|
78
|
-
export { STARTER_TEMPLATES, starterTemplate, type StarterTemplate } from './core/system-agents/templates.js';
|
|
77
|
+
export { TemplateError, createFromTemplate, describeTemplateSchedule, listTemplates, previewTemplate, templateChecklist, type CreatedTemplateAgent, type LegacyTemplateCreateDeps, type TemplateChecklistItem, type TemplateCreateDeps, type TemplatePreview, type TemplateSummary, } from './web/templates-admin.js';
|
|
78
|
+
export { STARTER_TEMPLATES, starterTemplate, templateScheduleStart, type StarterTemplate, type TemplateRule, type TemplateSchedule, type TemplateSetup, type TemplateSkill, } from './core/system-agents/templates.js';
|
|
79
79
|
export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
80
80
|
export { playgroundHtml } from './web/playground.js';
|
|
81
81
|
export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
|
|
@@ -88,10 +88,11 @@ export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities,
|
|
|
88
88
|
export type { InstalledAbilityRecord, AbilityEnvDecl, UpgradeFileResult } from './bin/ability-manager.js';
|
|
89
89
|
export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
|
|
90
90
|
export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
|
|
91
|
-
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
|
|
91
|
+
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError, describeTimeZone, setWorkspaceTimeZone, TimeZoneError } from './web/global-config.js';
|
|
92
92
|
export { loopengineVersion } from './core/version.js';
|
|
93
93
|
export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
|
|
94
|
-
export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView } from './web/global-config.js';
|
|
94
|
+
export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView, TimeZoneView } from './web/global-config.js';
|
|
95
|
+
export { workspaceTimeZone, serverTimeZone, isValidTimeZone, TIME_ZONE_ENV } from '#core/time-zone.js';
|
|
95
96
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
96
97
|
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
|
97
98
|
export type { AgentTemplateOptions } from './bin/cli.js';
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { setUsageSink, recordUsage } from '#core/usage.js';
|
|
|
12
12
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
|
|
13
13
|
export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
|
|
14
14
|
export { describeNotifier } from '#core/http-notifier.js';
|
|
15
|
-
export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
|
|
15
|
+
export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, moveScheduledTasksToTimeZone, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
|
|
16
16
|
export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
|
|
17
17
|
export { firstSighting, handleInboundMessage, handleMessagingAction, settleThreadChoice, isPlainApproval } from '#core/messaging/engine.js';
|
|
18
18
|
export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
|
|
@@ -69,8 +69,8 @@ export { interviewPageHtml } from './web/interview-page.js';
|
|
|
69
69
|
export { InterviewError, answerInterview, createFromCandidate, createAgentFromCandidate, describeCandidate, deleteInterview, finishInterview, getInterview, interviewTools, listInterviews, pickAgentModel, pickInterviewModel, previewCandidate, startInterview, setInterviewProject, setupChecklist, } from './web/interview-admin.js';
|
|
70
70
|
export { runInterviewTurn, rankCandidates, validateCandidate, INTERVIEWER_PROMPT } from './core/system-agents/interviewer.js';
|
|
71
71
|
export { templatesPageHtml } from './web/templates-page.js';
|
|
72
|
-
export { TemplateError, createFromTemplate, listTemplates, previewTemplate } from './web/templates-admin.js';
|
|
73
|
-
export { STARTER_TEMPLATES, starterTemplate } from './core/system-agents/templates.js';
|
|
72
|
+
export { TemplateError, createFromTemplate, describeTemplateSchedule, listTemplates, previewTemplate, templateChecklist, } from './web/templates-admin.js';
|
|
73
|
+
export { STARTER_TEMPLATES, starterTemplate, templateScheduleStart, } from './core/system-agents/templates.js';
|
|
74
74
|
export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
75
75
|
export { playgroundHtml } from './web/playground.js';
|
|
76
76
|
export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
|
|
@@ -78,8 +78,9 @@ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, setAgentEnvVar, unsetAgent
|
|
|
78
78
|
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
79
79
|
export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
|
|
80
80
|
export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
|
|
81
|
-
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
|
|
81
|
+
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError, describeTimeZone, setWorkspaceTimeZone, TimeZoneError } from './web/global-config.js';
|
|
82
82
|
export { loopengineVersion } from './core/version.js';
|
|
83
83
|
export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
|
|
84
|
+
export { workspaceTimeZone, serverTimeZone, isValidTimeZone, TIME_ZONE_ENV } from '#core/time-zone.js';
|
|
84
85
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
85
86
|
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
|
@@ -641,7 +641,7 @@ ${consoleShellScript}
|
|
|
641
641
|
var messagingDetail = [];
|
|
642
642
|
if (pending.length) messagingDetail.push(pending.length + ' waiting for a decision');
|
|
643
643
|
if (n && connected && n.channel === 'slack' && n.plan !== 'free' && (!n.file || n.file.conversations !== false)) messagingDetail.push('answers replies in threads');
|
|
644
|
-
if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Slack, Lark or a webhook');
|
|
644
|
+
if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Slack, Lark, email or a webhook');
|
|
645
645
|
if (!messagingDetail.length && n) messagingDetail.push('Sends ' + escapeHtml((n.events || []).join(', ')));
|
|
646
646
|
set('ovMessagingDetail', messagingDetail.join(' \u00b7 '));
|
|
647
647
|
showPendingCount(pending.length);
|
|
@@ -1076,7 +1076,7 @@ ${consoleShellScript}
|
|
|
1076
1076
|
if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
|
|
1077
1077
|
if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
|
|
1078
1078
|
if (n.source === 'default') return 'Using the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + '). Set one below to use a different target for this agent.';
|
|
1079
|
-
return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a
|
|
1079
|
+
return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a Slack channel, Lark chat, email address or webhook below.';
|
|
1080
1080
|
}
|
|
1081
1081
|
|
|
1082
1082
|
// Slack only: whether replies in a request's thread are answered. On
|
|
@@ -1114,7 +1114,7 @@ ${consoleShellScript}
|
|
|
1114
1114
|
function secretPlaceholder(isSet, missing) {
|
|
1115
1115
|
return isSet ? 'Saved ' + String.fromCharCode(8212) + ' type a new one only to replace it' : missing;
|
|
1116
1116
|
}
|
|
1117
|
-
var SECRET_HINTS = { slack: 'xoxb-' + String.fromCharCode(8230) + ' from OAuth & Permissions', webhook: 'A long random string, or Generate', lark: 'App Secret from the Lark developer console' };
|
|
1117
|
+
var SECRET_HINTS = { slack: 'xoxb-' + String.fromCharCode(8230) + ' from OAuth & Permissions', webhook: 'A long random string, or Generate', lark: 'App Secret from the Lark developer console', email: 'An API key from your email provider' };
|
|
1118
1118
|
function secretField(channel, label) {
|
|
1119
1119
|
var isSet = n.secretsSet && n.secretsSet[channel];
|
|
1120
1120
|
return '<label>' + secretHead(label, isSet) + '<span class="hint">(stored in this agent's env as ' + escapeHtml(n.secretVars[channel]) + ')</span>' +
|
|
@@ -1123,7 +1123,7 @@ ${consoleShellScript}
|
|
|
1123
1123
|
}
|
|
1124
1124
|
return '<form class="add-source" id="notifierForm" style="max-width:560px">' +
|
|
1125
1125
|
'<label>Send to<select name="channel" id="notifierChannel">' +
|
|
1126
|
-
['webhook', 'slack', 'lark'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat' }[c] + '</option>'; }).join('') +
|
|
1126
|
+
['webhook', 'slack', 'lark', 'email'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat', email: 'Email' }[c] + '</option>'; }).join('') +
|
|
1127
1127
|
'</select></label>' +
|
|
1128
1128
|
field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret') +
|
|
1129
1129
|
'<p class="hint" style="margin:0">Your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>') +
|
|
@@ -1149,6 +1149,14 @@ ${consoleShellScript}
|
|
|
1149
1149
|
renderConversationsField(n, f) +
|
|
1150
1150
|
'<p class="hint" style="margin:0" id="slackEventsNote"></p>') +
|
|
1151
1151
|
field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
|
|
1152
|
+
field('email',
|
|
1153
|
+
'<label>Email addresses <span class="hint">(who gets the requests; separate several with commas)</span><input name="emailTo" placeholder="ops@example.com, lead@example.com" value="' + escapeHtml(f.to || '') + '"></label>' +
|
|
1154
|
+
'<label>Sent by <span class="hint">(your email provider must allow sending from this address)</span><input name="emailFrom" placeholder="LoopEngine <approvals@example.com>" value="' + escapeHtml(f.from || '') + '"></label>' +
|
|
1155
|
+
'<label>Email provider<select name="emailProvider">' +
|
|
1156
|
+
[['resend', 'Resend'], ['sendgrid', 'SendGrid'], ['postmark', 'Postmark']].map(function (p) { return '<option value="' + p[0] + '"' + (f.provider === p[0] ? ' selected' : '') + '>' + p[1] + '</option>'; }).join('') +
|
|
1157
|
+
'</select></label>' +
|
|
1158
|
+
secretField('email', 'API key') +
|
|
1159
|
+
'<p class="hint" style="margin:0">Each email links to a page where the person approves, denies, edits or answers, with no login. The link address below must be one they can open.</p>') +
|
|
1152
1160
|
'<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
|
|
1153
1161
|
'<input type="url" name="publicUrl" id="notifierPublicUrl" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
|
|
1154
1162
|
'<div class="body-field">Send' +
|
|
@@ -1392,6 +1400,7 @@ ${consoleShellScript}
|
|
|
1392
1400
|
var data = new FormData(form);
|
|
1393
1401
|
var channel = data.get('channel');
|
|
1394
1402
|
var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
|
|
1403
|
+
if (channel === 'email') { body.to = data.get('emailTo'); body.from = data.get('emailFrom'); body.provider = data.get('emailProvider'); }
|
|
1395
1404
|
var secret = data.get('secret_' + channel);
|
|
1396
1405
|
if (secret) body.secret = secret;
|
|
1397
1406
|
if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
|
|
@@ -1475,8 +1484,22 @@ ${consoleShellScript}
|
|
|
1475
1484
|
scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
|
|
1476
1485
|
};
|
|
1477
1486
|
|
|
1487
|
+
// The workspace's time zone, from GET /agents/:name/schedules (an
|
|
1488
|
+
// older project adapter doesn't send it: then the browser's own).
|
|
1489
|
+
var schedulesTimeZone = null;
|
|
1490
|
+
|
|
1491
|
+
function fmtScheduleTime(iso) {
|
|
1492
|
+
var d = new Date(iso);
|
|
1493
|
+
if (schedulesTimeZone) {
|
|
1494
|
+
try { return d.toLocaleString(undefined, { timeZone: schedulesTimeZone, timeZoneName: 'short' }); } catch (e) {}
|
|
1495
|
+
}
|
|
1496
|
+
return d.toLocaleString();
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1478
1499
|
function scheduleText(s) {
|
|
1479
|
-
|
|
1500
|
+
// A repeating task's anchor is its first run, so it says when the
|
|
1501
|
+
// later ones fall (e.g. every 1d from 9:00).
|
|
1502
|
+
return s.run_at ? 'Once, ' + escapeHtml(fmtScheduleTime(s.run_at)) : 'Every ' + escapeHtml(s.every) + (s.anchor ? '<div class="hint">from ' + escapeHtml(fmtScheduleTime(s.anchor)) + '</div>' : '');
|
|
1480
1503
|
}
|
|
1481
1504
|
|
|
1482
1505
|
function renderScheduleRow(t) {
|
|
@@ -1488,17 +1511,17 @@ ${consoleShellScript}
|
|
|
1488
1511
|
if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
|
|
1489
1512
|
var last = t.last_run_at
|
|
1490
1513
|
? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
|
|
1491
|
-
'<span class="hint">' + escapeHtml(
|
|
1514
|
+
'<span class="hint">' + escapeHtml(fmtScheduleTime(t.last_run_at)) + '</span>' +
|
|
1492
1515
|
'<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
|
|
1493
1516
|
: '<span class="hint">not run yet</span>';
|
|
1494
1517
|
var history = (t.history || []).slice().reverse().map(function (h) {
|
|
1495
|
-
return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(
|
|
1518
|
+
return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(fmtScheduleTime(h.ran_at)) + '</span>' +
|
|
1496
1519
|
'<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
|
|
1497
1520
|
}).join('');
|
|
1498
1521
|
return '<tr>' +
|
|
1499
1522
|
'<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' + (t.notify ? ' <span class="le-pill accent" title="Each result goes through this agent's notifier">notifies</span>' : '') +
|
|
1500
1523
|
'<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
|
|
1501
|
-
'<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(
|
|
1524
|
+
'<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
|
|
1502
1525
|
'<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
|
|
1503
1526
|
(t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
|
|
1504
1527
|
'<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
|
|
@@ -1520,7 +1543,10 @@ ${consoleShellScript}
|
|
|
1520
1543
|
'<div class="body-field">When' +
|
|
1521
1544
|
'<div class="schedule-when"><input type="radio" name="when" value="every" id="whenEvery" checked><label for="whenEvery">Every</label>' +
|
|
1522
1545
|
'<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
|
|
1523
|
-
'<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option></select
|
|
1546
|
+
'<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option><option value="w">weeks</option></select>' +
|
|
1547
|
+
'<label for="everyStart">starting</label><input type="datetime-local" name="everyStart" id="everyStart" aria-label="First run"></div>' +
|
|
1548
|
+
'<p class="hint" style="margin:0 0 4px 22px">Optional. The first run, which sets the time of day (and the weekday, for weeks) every later run keeps, e.g. every 1 day starting tomorrow 09:00. Without it, the first run is one interval from now.' +
|
|
1549
|
+
(schedulesTimeZone ? ' Times are in ' + escapeHtml(schedulesTimeZone.replace(/_/g, ' ')) + ' (<a href="/config?section=settings">change</a>).' : '') + '</p>' +
|
|
1524
1550
|
'<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
|
|
1525
1551
|
'<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
|
|
1526
1552
|
'</div>' +
|
|
@@ -1531,6 +1557,13 @@ ${consoleShellScript}
|
|
|
1531
1557
|
'</form></section>';
|
|
1532
1558
|
}
|
|
1533
1559
|
|
|
1560
|
+
// A date field's value ("2026-11-02T09:00") goes as it is, for the
|
|
1561
|
+
// server to read in the workspace's time zone; with no workspace time
|
|
1562
|
+
// zone known, it's the browser's.
|
|
1563
|
+
function scheduleFormTime(value) {
|
|
1564
|
+
return schedulesTimeZone ? value : new Date(value).toISOString();
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1534
1567
|
function wireSchedulesHandlers(name) {
|
|
1535
1568
|
var content = schedulesContentEl();
|
|
1536
1569
|
if (!content) return;
|
|
@@ -1563,9 +1596,13 @@ ${consoleShellScript}
|
|
|
1563
1596
|
var schedule;
|
|
1564
1597
|
if (data.get('when') === 'once') {
|
|
1565
1598
|
if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
|
|
1566
|
-
schedule = { run_at:
|
|
1599
|
+
schedule = { run_at: scheduleFormTime(data.get('runAt')) };
|
|
1567
1600
|
} else {
|
|
1568
|
-
|
|
1601
|
+
// Weeks go to the scheduler as days.
|
|
1602
|
+
var unit = data.get('everyUnit');
|
|
1603
|
+
var n = Number(data.get('everyN')) || 1;
|
|
1604
|
+
schedule = { every: unit === 'w' ? String(n * 7) + 'd' : String(n) + unit };
|
|
1605
|
+
if (data.get('everyStart')) schedule.start_at = scheduleFormTime(data.get('everyStart'));
|
|
1569
1606
|
}
|
|
1570
1607
|
result.textContent = 'Scheduling' + String.fromCharCode(8230);
|
|
1571
1608
|
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
|
|
@@ -1586,6 +1623,7 @@ ${consoleShellScript}
|
|
|
1586
1623
|
if (currentName !== name) return;
|
|
1587
1624
|
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
1588
1625
|
var tasks = r.body.tasks || [];
|
|
1626
|
+
schedulesTimeZone = r.body.time_zone || null;
|
|
1589
1627
|
content.innerHTML = renderSchedulesHtml(tasks);
|
|
1590
1628
|
wireSchedulesHandlers(name);
|
|
1591
1629
|
leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
|
|
@@ -298,14 +298,78 @@ ${consoleShellScript}
|
|
|
298
298
|
['Environment name', '<code>' + escapeHtml(d.environment) + '</code> <span class="hint">(LOOPENGINE_ENV)</span>'],
|
|
299
299
|
['Public address', d.publicUrl ? '<code>' + escapeHtml(d.publicUrl) + '</code>' : '<span class="hint">Not set (LOOPENGINE_PUBLIC_URL); links in notifications use the address saved with each notifier</span>'],
|
|
300
300
|
['Scheduled tasks', d.scheduler === 'off' ? 'Off on this server (LOOPENGINE_SCHEDULER=off)' : 'Run by this server'],
|
|
301
|
+
['Time zone', d.timeZone ? '<code>' + escapeHtml(d.timeZone) + '</code>' : '<span class="hint">The server' + String.fromCharCode(8217) + 's own. Update the template below to choose one here.</span>'],
|
|
301
302
|
['Theme', 'Follows your system'],
|
|
302
303
|
];
|
|
303
304
|
return '<section><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody>' +
|
|
304
305
|
rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
|
|
305
306
|
'</tbody></table></section>' +
|
|
307
|
+
(d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
|
|
306
308
|
'<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
|
|
307
309
|
}
|
|
308
310
|
|
|
311
|
+
// ---- Time zone ----
|
|
312
|
+
|
|
313
|
+
function renderTimeZone(t) {
|
|
314
|
+
var box = document.getElementById('timeZoneBox');
|
|
315
|
+
if (!box) return;
|
|
316
|
+
var intro = '<p>Schedules and templates use this for times of day: “daily at 09:00” runs at 9:00 here, and daily and weekly schedules keep that time when the clocks change. ' +
|
|
317
|
+
(t.chosen ? '' : 'Not chosen yet, so it' + String.fromCharCode(8217) + 's the server' + String.fromCharCode(8217) + 's (<code>' + escapeHtml(t.server) + '</code>).') + '</p>';
|
|
318
|
+
if (!t.canSave) {
|
|
319
|
+
box.innerHTML = intro + '<p>In use: <code>' + escapeHtml(t.timeZone) + '</code></p><p class="hint">Set LOOPENGINE_ADMIN_AUTH to change it here, or put <code>LOOPENGINE_TIME_ZONE</code> in .env.</p>';
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
var browserZone = '';
|
|
323
|
+
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
|
|
324
|
+
var options = t.zones.map(function (z) {
|
|
325
|
+
return '<option value="' + escapeHtml(z) + '"' + (z === t.timeZone ? ' selected' : '') + '>' + escapeHtml(z.replace(/_/g, ' ')) + '</option>';
|
|
326
|
+
}).join('');
|
|
327
|
+
box.innerHTML = intro +
|
|
328
|
+
'<form id="timeZoneForm" style="display:flex;flex-direction:column;gap:10px;max-width:480px">' +
|
|
329
|
+
'<label for="timeZoneSelect">Time zone</label>' +
|
|
330
|
+
'<select id="timeZoneSelect" name="timeZone">' + options + '</select>' +
|
|
331
|
+
(browserZone && browserZone !== t.timeZone && t.zones.indexOf(browserZone) >= 0
|
|
332
|
+
? '<p class="hint" style="margin:0"><a href="#" id="timeZoneUseBrowser">Use this browser' + String.fromCharCode(8217) + 's, ' + escapeHtml(browserZone) + '</a></p>' : '') +
|
|
333
|
+
'<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="moveSchedules" checked> Move existing daily and weekly schedules too, keeping their time of day</label>' +
|
|
334
|
+
'<div><button type="submit" class="primary">Save</button></div>' +
|
|
335
|
+
'<div id="timeZoneMsg" class="hint"></div>' +
|
|
336
|
+
'</form>';
|
|
337
|
+
var form = document.getElementById('timeZoneForm');
|
|
338
|
+
var useBrowser = document.getElementById('timeZoneUseBrowser');
|
|
339
|
+
if (useBrowser) useBrowser.addEventListener('click', function (ev) { ev.preventDefault(); form.timeZone.value = browserZone; });
|
|
340
|
+
form.addEventListener('submit', function (ev) {
|
|
341
|
+
ev.preventDefault();
|
|
342
|
+
var msg = document.getElementById('timeZoneMsg');
|
|
343
|
+
var btn = form.querySelector('button[type="submit"]');
|
|
344
|
+
btn.disabled = true;
|
|
345
|
+
msg.textContent = 'Saving' + String.fromCharCode(8230);
|
|
346
|
+
fetch('/config/time-zone', {
|
|
347
|
+
method: 'PUT',
|
|
348
|
+
headers: { 'content-type': 'application/json' },
|
|
349
|
+
body: JSON.stringify({ timeZone: form.timeZone.value, moveSchedules: form.moveSchedules.checked }),
|
|
350
|
+
})
|
|
351
|
+
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
|
352
|
+
.then(function (r) {
|
|
353
|
+
btn.disabled = false;
|
|
354
|
+
if (!r.ok) { msg.innerHTML = '<span class="error">' + escapeHtml(r.body.error || 'Could not save') + '</span>'; return; }
|
|
355
|
+
msg.textContent = 'Saved: ' + r.body.timeZone.replace(/_/g, ' ') + '.' +
|
|
356
|
+
(r.body.moved ? ' Moved ' + r.body.moved + ' schedule' + (r.body.moved === 1 ? '' : 's') + '.' : '');
|
|
357
|
+
})
|
|
358
|
+
.catch(function (err) { btn.disabled = false; msg.innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'; });
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function loadTimeZone() {
|
|
363
|
+
if (!document.getElementById('timeZoneBox')) return;
|
|
364
|
+
fetch('/config/time-zone', { cache: 'no-store' })
|
|
365
|
+
.then(function (r) { return r.json(); })
|
|
366
|
+
.then(renderTimeZone)
|
|
367
|
+
.catch(function (err) {
|
|
368
|
+
var box = document.getElementById('timeZoneBox');
|
|
369
|
+
if (box) box.innerHTML = '<p class="error">Could not load the time zone: ' + escapeHtml(err.message) + '</p>';
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
309
373
|
// ---- Upgrade ----
|
|
310
374
|
|
|
311
375
|
var UPGRADE_NOTE = 'Updates this server' + String.fromCharCode(8217) + 's LoopEngine code and restarts it, which takes a minute or two. ' +
|
|
@@ -436,6 +500,7 @@ ${consoleShellScript}
|
|
|
436
500
|
.then(function (d) {
|
|
437
501
|
content.innerHTML = renderSettings(d);
|
|
438
502
|
loadedSections.settings = true;
|
|
503
|
+
loadTimeZone();
|
|
439
504
|
loadUpgrade();
|
|
440
505
|
})
|
|
441
506
|
.catch(function (err) {
|
|
@@ -69,3 +69,27 @@ export declare function modelKeyEnvVar(provider: string): string | undefined;
|
|
|
69
69
|
*/
|
|
70
70
|
export declare function setModelProviderKey(provider: string, key: unknown): Promise<void>;
|
|
71
71
|
export declare function clearModelProviderKey(provider: string): Promise<void>;
|
|
72
|
+
export declare class TimeZoneError extends Error {
|
|
73
|
+
}
|
|
74
|
+
export interface TimeZoneView {
|
|
75
|
+
/** The one in use. */
|
|
76
|
+
timeZone: string;
|
|
77
|
+
/** Whether it was chosen (LOOPENGINE_TIME_ZONE), not the server's. */
|
|
78
|
+
chosen: boolean;
|
|
79
|
+
server: string;
|
|
80
|
+
zones: string[];
|
|
81
|
+
}
|
|
82
|
+
export declare function describeTimeZone(): TimeZoneView;
|
|
83
|
+
/**
|
|
84
|
+
* The console's Settings → Time zone: saved like a model key (the
|
|
85
|
+
* project's .env by default) and applied to this process at once. New
|
|
86
|
+
* schedules and template start times use it from then on; with
|
|
87
|
+
* `moveSchedules`, existing daily and weekly schedules move to it too,
|
|
88
|
+
* keeping their time of day. An empty value goes back to the server's.
|
|
89
|
+
*/
|
|
90
|
+
export declare function setWorkspaceTimeZone(value: unknown, options?: {
|
|
91
|
+
moveSchedules?: boolean;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
timeZone: string;
|
|
94
|
+
moved: number;
|
|
95
|
+
}>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getComposioAuthStatus } from '../core/gateway-tools.js';
|
|
2
|
+
import { moveScheduledTasksToTimeZone } from '../core/scheduler.js';
|
|
3
|
+
import { TIME_ZONE_ENV, isValidTimeZone, listTimeZones, serverTimeZone, workspaceTimeZone } from '../core/time-zone.js';
|
|
2
4
|
import { setEnvVar, unsetEnvVar } from './env-admin.js';
|
|
3
5
|
export class ModelKeyError extends Error {
|
|
4
6
|
}
|
|
@@ -102,3 +104,31 @@ export async function clearModelProviderKey(provider) {
|
|
|
102
104
|
throw new ModelKeyError(`unknown provider '${provider}'`);
|
|
103
105
|
await unsetEnvVar(envVar);
|
|
104
106
|
}
|
|
107
|
+
export class TimeZoneError extends Error {
|
|
108
|
+
}
|
|
109
|
+
export function describeTimeZone() {
|
|
110
|
+
const timeZone = workspaceTimeZone();
|
|
111
|
+
const configured = process.env[TIME_ZONE_ENV]?.trim();
|
|
112
|
+
return { timeZone, chosen: Boolean(configured && isValidTimeZone(configured)), server: serverTimeZone(), zones: listTimeZones() };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The console's Settings → Time zone: saved like a model key (the
|
|
116
|
+
* project's .env by default) and applied to this process at once. New
|
|
117
|
+
* schedules and template start times use it from then on; with
|
|
118
|
+
* `moveSchedules`, existing daily and weekly schedules move to it too,
|
|
119
|
+
* keeping their time of day. An empty value goes back to the server's.
|
|
120
|
+
*/
|
|
121
|
+
export async function setWorkspaceTimeZone(value, options = {}) {
|
|
122
|
+
const requested = typeof value === 'string' ? value.trim() : '';
|
|
123
|
+
if (requested && !isValidTimeZone(requested))
|
|
124
|
+
throw new TimeZoneError(`"${requested}" isn't a time zone this server knows, such as America/New_York or Europe/London`);
|
|
125
|
+
const before = workspaceTimeZone();
|
|
126
|
+
// Saved by its canonical name ("america/new_york" → "America/New_York").
|
|
127
|
+
if (requested)
|
|
128
|
+
await setEnvVar(TIME_ZONE_ENV, new Intl.DateTimeFormat('en-US', { timeZone: requested }).resolvedOptions().timeZone);
|
|
129
|
+
else
|
|
130
|
+
await unsetEnvVar(TIME_ZONE_ENV);
|
|
131
|
+
const timeZone = workspaceTimeZone();
|
|
132
|
+
const moved = options.moveSchedules && timeZone !== before ? await moveScheduledTasksToTimeZone(before, timeZone) : 0;
|
|
133
|
+
return { timeZone, moved };
|
|
134
|
+
}
|
|
@@ -143,7 +143,7 @@ function activeNotifierTarget(config, defaultWebhook) {
|
|
|
143
143
|
const dir = agentDirFor(config);
|
|
144
144
|
const file = readNotifierFile(dir);
|
|
145
145
|
if (file) {
|
|
146
|
-
const { config: fromFile } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
146
|
+
const { config: fromFile } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
|
|
147
147
|
if (fromFile)
|
|
148
148
|
return fromFile;
|
|
149
149
|
}
|
|
@@ -185,7 +185,7 @@ export async function testAgentNotifier(config, defaultWebhook) {
|
|
|
185
185
|
const dir = agentDirFor(config);
|
|
186
186
|
const file = readNotifierFile(dir);
|
|
187
187
|
if (file) {
|
|
188
|
-
const { config: fromFile, missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
188
|
+
const { config: fromFile, missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
|
|
189
189
|
if (!fromFile)
|
|
190
190
|
return { ok: false, channel: file.channel, error: `${missingSecret} is not set for this agent` };
|
|
191
191
|
target = fromFile;
|
|
@@ -202,6 +202,15 @@ export async function testAgentNotifier(config, defaultWebhook) {
|
|
|
202
202
|
if (target.channel === 'slack') {
|
|
203
203
|
return { channel: 'slack', ...(await sendTestSlackMessage(target.config.botToken, target.config.channelId, `Test from LoopEngine: approvals for ${config.name} will be posted here.`)) };
|
|
204
204
|
}
|
|
205
|
+
if (target.channel === 'email') {
|
|
206
|
+
try {
|
|
207
|
+
await target.config.sendEmail(target.config.to, `Test from LoopEngine: ${config.name}`, `<p>This is a test. Approval requests and questions from <b>${config.name}</b> will be emailed here, each with a link to approve, deny or answer.</p>`, { kind: 'test' });
|
|
208
|
+
return { channel: 'email', ok: true };
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
return { channel: 'email', ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
205
214
|
return { ok: false, channel: target.channel, error: `Send test isn't supported for ${target.channel} yet` };
|
|
206
215
|
}
|
|
207
216
|
/** Every decision still waiting for this agent, oldest first, each with
|