dsh-scheduled-actions 0.1.1 → 0.2.0
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 +1 -0
- package/lib/client.js +126 -10
- package/lib/domain.js +7 -0
- package/lib/execution.js +23 -1
- package/lib/index.js +133 -4
- package/lib/scheduler.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- **SchedulerEngine** на `setTimeout` — не привязан к сессии, работает на уровне хоста.
|
|
20
20
|
- **Валидация времени** через встроенный `Intl.DateTimeFormat` (IANA-зоны, DST) — без внешних зависимостей.
|
|
21
21
|
- **UI в Настройках**: секция «Запланированные действия» — таблица, фильтр «показать прошедшие», массовое удаление, форма создания/редактирования с проверкой, запуск «выполнить сейчас».
|
|
22
|
+
- **Куда отправлять запрос** (для `agent-prompt`): по умолчанию — первому активному агенту, либо конкретный чат из списка живых, либо «создать чат для события» (имя = названию события), либо «каждый раз в новом чате» (имя = событие + дата запуска). Для создания чата выбирается workspace в форме.
|
|
22
23
|
- **AI-инструменты** для агентов: `scheduled_action_list`, `scheduled_action_create`, `scheduled_action_update`, `scheduled_action_delete`, `scheduled_action_run_now`.
|
|
23
24
|
- **RPC-мост** между браузером и хостом через `ctx.connection` (`/scheduled-actions/<method>`).
|
|
24
25
|
- **Полная обратимость**: при остановке/удалении плагина все таймеры и обработчики корректно отключаются.
|
package/lib/client.js
CHANGED
|
@@ -59,6 +59,17 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
const WEEKDAY_NAMES = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
|
|
62
|
+
const OVERDUE_DISMISS_KEY = "dsh-scheduled-actions:overdue-dismissed";
|
|
63
|
+
const loadDismissed = () => {
|
|
64
|
+
try {
|
|
65
|
+
const raw = localStorage.getItem(OVERDUE_DISMISS_KEY);
|
|
66
|
+
const v = raw ? JSON.parse(raw) : {};
|
|
67
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
68
|
+
} catch { return {}; }
|
|
69
|
+
};
|
|
70
|
+
const saveDismissed = (m) => {
|
|
71
|
+
try { localStorage.setItem(OVERDUE_DISMISS_KEY, JSON.stringify(m)); } catch { /* ignore */ }
|
|
72
|
+
};
|
|
62
73
|
|
|
63
74
|
const fmtSchedule = (schedule) => {
|
|
64
75
|
if (!schedule) return "—";
|
|
@@ -114,6 +125,16 @@ window.__ModuleLoader__.load({
|
|
|
114
125
|
return () => document.removeEventListener("mousedown", onDown);
|
|
115
126
|
}, [openMenu]);
|
|
116
127
|
const [selected, setSelected] = react.useState(new Set());
|
|
128
|
+
const [dismissed, setDismissed] = react.useState(() => loadDismissed());
|
|
129
|
+
const dismissOccurrence = (id, action) => {
|
|
130
|
+
const m = { ...dismissed, [id]: action.nextRunAt ?? null };
|
|
131
|
+
setDismissed(m); saveDismissed(m);
|
|
132
|
+
};
|
|
133
|
+
// Show in the banner only real wall-clock misses the user did not already
|
|
134
|
+
// dismiss for this exact occurrence. on-startup actions auto-run at host start
|
|
135
|
+
// and are never "missed".
|
|
136
|
+
const showInBanner = (a) => a.schedule?.type !== "on-startup"
|
|
137
|
+
&& dismissed[a.id] !== (a.nextRunAt ?? null);
|
|
117
138
|
|
|
118
139
|
const toggleOne = (id) => setSelected((prev) => {
|
|
119
140
|
const n = new Set(prev);
|
|
@@ -142,10 +163,16 @@ window.__ModuleLoader__.load({
|
|
|
142
163
|
} finally { setBusy(false); }
|
|
143
164
|
};
|
|
144
165
|
|
|
145
|
-
// A
|
|
146
|
-
//
|
|
166
|
+
// A finished action is completed/failed (no run ahead) or disabled. An
|
|
167
|
+
// ENABLED action whose scheduled time already passed is missed/pending: it
|
|
168
|
+
// stays in the active list so the user can open it and run it manually
|
|
169
|
+
// later instead of burying it among the finished ones.
|
|
147
170
|
const isPast = (a) => {
|
|
148
171
|
if (a.state === "completed" || a.state === "failed") return true;
|
|
172
|
+
if (a.state === "idle" && a.enabled && a.nextRunAt) {
|
|
173
|
+
const t = Date.parse(a.nextRunAt);
|
|
174
|
+
if (Number.isFinite(t) && t <= Date.now()) return false; // missed -> pending
|
|
175
|
+
}
|
|
149
176
|
if (a.state === "idle" && a.nextRunAt) {
|
|
150
177
|
const t = Date.parse(a.nextRunAt);
|
|
151
178
|
if (Number.isFinite(t) && t <= Date.now()) return true;
|
|
@@ -172,7 +199,7 @@ window.__ModuleLoader__.load({
|
|
|
172
199
|
const [list, od] = await Promise.all([rpc("list", {}), rpc("checkOverdue", {})]);
|
|
173
200
|
if (!alive) return;
|
|
174
201
|
setActions(Array.isArray(list) ? list : []);
|
|
175
|
-
setOverdue(Array.isArray(od) ? od : []);
|
|
202
|
+
setOverdue(Array.isArray(od) ? od.filter(showInBanner) : []);
|
|
176
203
|
} catch (e) {
|
|
177
204
|
if (alive) setError(e.message);
|
|
178
205
|
}
|
|
@@ -265,7 +292,19 @@ window.__ModuleLoader__.load({
|
|
|
265
292
|
} finally { setBusy(false); }
|
|
266
293
|
};
|
|
267
294
|
|
|
268
|
-
const
|
|
295
|
+
const dismissOverdue = async (id, action) => {
|
|
296
|
+
// Remember locally first so the message never re-appears on the next
|
|
297
|
+
// visit even before the host endpoint is reachable.
|
|
298
|
+
dismissOccurrence(id, action);
|
|
299
|
+
setOverdue((cur) => cur.filter((a) => a.id !== id));
|
|
300
|
+
setBusy(true); setError(null);
|
|
301
|
+
try {
|
|
302
|
+
await rpc("dismissOverdue", { id });
|
|
303
|
+
} catch (e) {
|
|
304
|
+
// Host lands the persistent dismissal after a DSH restart; the local
|
|
305
|
+
// record above already hides this occurrence in this browser.
|
|
306
|
+
} finally { setBusy(false); }
|
|
307
|
+
};
|
|
269
308
|
const runOverdue = async (id) => {
|
|
270
309
|
setBusy(true); setError(null);
|
|
271
310
|
try {
|
|
@@ -288,7 +327,7 @@ window.__ModuleLoader__.load({
|
|
|
288
327
|
overdue.map((a) => jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [
|
|
289
328
|
jsx("span", { style: { flex: 1, fontSize: 13, color: "var(--dsw-alias-label-primary)" }, children: a.label }),
|
|
290
329
|
jsx(button, { disabled: busy, onClick: () => runOverdue(a.id), children: "Выполнить" }),
|
|
291
|
-
jsx(button, { disabled: busy, onClick:
|
|
330
|
+
jsx(button, { disabled: busy, onClick: () => dismissOverdue(a.id, a), children: "Позже" }),
|
|
292
331
|
] }, a.id)),
|
|
293
332
|
] }) : null,
|
|
294
333
|
|
|
@@ -325,9 +364,9 @@ window.__ModuleLoader__.load({
|
|
|
325
364
|
jsx("td", { colSpan: 6, style: { ...tdStyle, color: "var(--dsw-alias-label-tertiary)", textAlign: "center" }, children: "Нет действий" }) }) :
|
|
326
365
|
visibleActions.map((a) => jsxs("tr", { style: rowStyle, children: [
|
|
327
366
|
jsx("td", { style: { ...tdStyle, ...stickyCheck, width: 34 }, children: isPast(a) ? jsx("input", { type: "checkbox", checked: selected.has(a.id), onChange: () => toggleOne(a.id) }) : null }),
|
|
328
|
-
jsx("td", { style: { ...tdStyle, ...stickyName }, children: jsxs("div", { style: {
|
|
329
|
-
jsx("span", { style: { fontWeight: 500, color: "var(--dsw-alias-label-primary)" }, children: a.label }),
|
|
330
|
-
jsx("span", { style: smallText, children: a.description || a.id }),
|
|
367
|
+
jsx("td", { style: { ...tdStyle, ...stickyName }, children: jsxs("div", { style: { position: "relative", height: 36, minWidth: 0 }, children: [
|
|
368
|
+
jsx("span", { title: a.label, style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: "18px", maxWidth: 240, fontWeight: 500, color: "var(--dsw-alias-label-primary)" }, children: a.label }),
|
|
369
|
+
jsx("span", { title: a.description || a.id, style: { ...smallText, position: "absolute", left: 0, right: 0, bottom: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: "12px" }, children: a.description || a.id }),
|
|
331
370
|
] }) }),
|
|
332
371
|
jsx("td", { style: tdStyle, children: fmtSchedule(a.schedule) }),
|
|
333
372
|
jsx("td", { style: tdStyle, children: a.schedule?.type === "on-startup" ? "При запуске" : fmtDate(a.nextRunAt) }),
|
|
@@ -431,8 +470,51 @@ window.__ModuleLoader__.load({
|
|
|
431
470
|
const [startMode, setStartMode] = react.useState(initial?.schedule?.startMode ?? "everyStart");
|
|
432
471
|
const [expr, setExpr] = react.useState(initial?.schedule?.cronExpr ?? "0 9 * * *");
|
|
433
472
|
const [enabled, setEnabled] = react.useState(initial?.enabled ?? true);
|
|
473
|
+
// Chat target picker: "" = default, a session id = existing chat,
|
|
474
|
+
// "__new_chat__" = create one dedicated chat, "__new_per_run__" = fresh chat each run.
|
|
475
|
+
const [chatTarget, setChatTarget] = react.useState(() => {
|
|
476
|
+
if (initial?.chatPolicy === "new-per-run") return "__new_per_run__";
|
|
477
|
+
if (initial?.chatPolicy === "new-chat") return "__new_chat__";
|
|
478
|
+
return initial?.targetAgentId ?? "";
|
|
479
|
+
});
|
|
480
|
+
const [workspaceId, setWorkspaceId] = react.useState(initial?.chatWorkspaceId ?? "");
|
|
481
|
+
const [chats, setChats] = react.useState([]);
|
|
482
|
+
const [chatsState, setChatsState] = react.useState("idle"); // idle | loading | ready | error
|
|
483
|
+
const [workspaces, setWorkspaces] = react.useState([]);
|
|
484
|
+
const [workspacesState, setWorkspacesState] = react.useState("idle");
|
|
434
485
|
const [formError, setFormError] = react.useState(false);
|
|
435
486
|
|
|
487
|
+
react.useEffect(() => {
|
|
488
|
+
let alive = true;
|
|
489
|
+
setChatsState("loading");
|
|
490
|
+
rpc("listChats", {}).then((list) => {
|
|
491
|
+
if (!alive) return;
|
|
492
|
+
setChats(Array.isArray(list) ? list : []);
|
|
493
|
+
setChatsState("ready");
|
|
494
|
+
}, () => {
|
|
495
|
+
if (!alive) return;
|
|
496
|
+
setChats([]);
|
|
497
|
+
setChatsState("error");
|
|
498
|
+
});
|
|
499
|
+
return () => { alive = false; };
|
|
500
|
+
}, []);
|
|
501
|
+
|
|
502
|
+
react.useEffect(() => {
|
|
503
|
+
let alive = true;
|
|
504
|
+
setWorkspacesState("loading");
|
|
505
|
+
rpc("listWorkspaces", {}).then((list) => {
|
|
506
|
+
if (!alive) return;
|
|
507
|
+
setWorkspaces(Array.isArray(list) ? list : []);
|
|
508
|
+
setWorkspacesState("ready");
|
|
509
|
+
setWorkspaceId((cur) => cur || (Array.isArray(list) && list[0] ? list[0].id : ""));
|
|
510
|
+
}, () => {
|
|
511
|
+
if (!alive) return;
|
|
512
|
+
setWorkspaces([]);
|
|
513
|
+
setWorkspacesState("error");
|
|
514
|
+
});
|
|
515
|
+
return () => { alive = false; };
|
|
516
|
+
}, []);
|
|
517
|
+
|
|
436
518
|
const showInvalid = () => setFormError(true);
|
|
437
519
|
|
|
438
520
|
// Domain schedules "daily"/"weekly" in the browser's local IANA zone.
|
|
@@ -466,7 +548,19 @@ window.__ModuleLoader__.load({
|
|
|
466
548
|
else if (type === "cron") schedule = { type: "cron", cronExpr: expr };
|
|
467
549
|
else if (type === "on-startup") schedule = { type: "on-startup", startMode };
|
|
468
550
|
else schedule = { type: "one-shot", at: new Date(Date.now() + 3600000).toISOString() };
|
|
469
|
-
|
|
551
|
+
// Chat target: existing chat id -> targetAgentId; new-chat modes ->
|
|
552
|
+
// chatPolicy + chatWorkspaceId (host creates the session on dispatch).
|
|
553
|
+
let chatFields = { targetAgentId: undefined, chatPolicy: undefined, chatWorkspaceId: undefined };
|
|
554
|
+
if (kind === "agent-prompt") {
|
|
555
|
+
if (chatTarget === "__new_chat__") {
|
|
556
|
+
chatFields = { chatPolicy: "new-chat", chatWorkspaceId: workspaceId || undefined };
|
|
557
|
+
} else if (chatTarget === "__new_per_run__") {
|
|
558
|
+
chatFields = { chatPolicy: "new-per-run", chatWorkspaceId: workspaceId || undefined };
|
|
559
|
+
} else if (chatTarget && chatTarget !== "") {
|
|
560
|
+
chatFields = { targetAgentId: chatTarget };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
onSave({ label, description, kind, payload, schedule, enabled, ...chatFields });
|
|
470
564
|
};
|
|
471
565
|
|
|
472
566
|
const field = (lbl, value, set, ph = "", type = "text", highlight = false) => jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
|
|
@@ -494,6 +588,28 @@ window.__ModuleLoader__.load({
|
|
|
494
588
|
jsx("span", { style: fieldLabel, children: kind === "agent-prompt" ? "Текст запроса" : "Команда" }),
|
|
495
589
|
jsx("textarea", { value: payload, rows: 3, style: { ...inputStyle, resize: "vertical", font: "inherit", padding: "8px 12px", ...(formError && !payload.trim() ? errorInputStyle : {}) }, onChange: (e) => setPayload(e.target.value) }),
|
|
496
590
|
] }),
|
|
591
|
+
kind === "agent-prompt" ? jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
|
|
592
|
+
jsx("span", { style: fieldLabel, children: "Куда выполнять запрос (необязательно)" }),
|
|
593
|
+
jsx("select", { value: chatTarget, style: inputStyle, onChange: (e) => setChatTarget(e.target.value), children: [
|
|
594
|
+
jsx("option", { value: "", children: "— по умолчанию (первый активный агент) —" }),
|
|
595
|
+
chatsState === "loading" ? jsx("option", { value: "", disabled: true, children: "Загрузка чатов…" }) : null,
|
|
596
|
+
chatsState === "error" ? jsx("option", { value: "", disabled: true, children: "Список чатов недоступен" }) : null,
|
|
597
|
+
chats.map((c) => jsx("option", { key: c.id, value: c.id, children: `${c.title} (${c.id})` })),
|
|
598
|
+
jsx("option", { value: "__new_chat__", children: "+ Создать чат для события (имя = названию)" }),
|
|
599
|
+
jsx("option", { value: "__new_per_run__", children: "+ Каждый раз в новом чате (имя + дата)" }),
|
|
600
|
+
] }),
|
|
601
|
+
(chatTarget === "__new_chat__" || chatTarget === "__new_per_run__") ? jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
|
|
602
|
+
jsx("span", { style: fieldLabel, children: "Где создать чат (workspace)" }),
|
|
603
|
+
jsx("select", { value: workspaceId, style: inputStyle, onChange: (e) => setWorkspaceId(e.target.value), children: [
|
|
604
|
+
workspacesState === "loading" ? jsx("option", { value: "", children: "Загрузка workspace…" }) : null,
|
|
605
|
+
workspaces.map((w) => jsx("option", { key: w.id, value: w.id, children: w.title })),
|
|
606
|
+
] }),
|
|
607
|
+
jsx("span", { style: { ...smallText }, children: chatTarget === "__new_chat__"
|
|
608
|
+
? `Чат создастся при первом срабатывании с именем «${label || "название события"}».`
|
|
609
|
+
: `При каждом срабатывании создастся новый чат с именем «${label || "название события"} + дата».` }),
|
|
610
|
+
] }) : null,
|
|
611
|
+
jsx("span", { style: { ...smallText }, children: "Не выбрано — запрос уйдёт первому активному агенту, как сейчас." }),
|
|
612
|
+
] }) : null,
|
|
497
613
|
sel("Расписание", type, setType, [
|
|
498
614
|
{ v: "one-shot", t: "Одноразово" },
|
|
499
615
|
{ v: "interval", t: "Интервал" },
|
|
@@ -742,4 +858,4 @@ window.__ModuleLoader__.load({
|
|
|
742
858
|
module.exports = { apply, inject };
|
|
743
859
|
return module.exports;
|
|
744
860
|
}
|
|
745
|
-
});
|
|
861
|
+
});
|
package/lib/domain.js
CHANGED
|
@@ -576,6 +576,13 @@ export function createScheduledAction(input, now) {
|
|
|
576
576
|
// executer delivers to the first root agent (or the invoking agent, for
|
|
577
577
|
// actions created by a tool). Preserved through decode; not validated.
|
|
578
578
|
targetAgentId: typeof input.targetAgentId === "string" ? input.targetAgentId : undefined,
|
|
579
|
+
// Chat-target policy for `agent-prompt` actions:
|
|
580
|
+
// - "new-chat": create ONE dedicated chat (in chatWorkspaceId) on the
|
|
581
|
+
// first run and remember its session as targetAgentId;
|
|
582
|
+
// - "new-per-run": create a fresh chat on every run (title = label + run date).
|
|
583
|
+
// Preserved through decode; not validated further.
|
|
584
|
+
chatPolicy: typeof input.chatPolicy === "string" ? input.chatPolicy : undefined,
|
|
585
|
+
chatWorkspaceId: typeof input.chatWorkspaceId === "string" ? input.chatWorkspaceId : undefined,
|
|
579
586
|
});
|
|
580
587
|
}
|
|
581
588
|
|
package/lib/execution.js
CHANGED
|
@@ -96,19 +96,41 @@ function resolveTargetAgent(ctx, action) {
|
|
|
96
96
|
return roots[0];
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Human-readable local run stamp, e.g. "2026-09-07 14:30". */
|
|
100
|
+
function runStamp(now) {
|
|
101
|
+
const d = new Date(now);
|
|
102
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
103
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
/**
|
|
100
107
|
* Build the default host executer closure.
|
|
101
108
|
*
|
|
102
109
|
* @param ctx - Host context (must inject `agents` when the agent-prompt kind is
|
|
103
110
|
* used; a missing `agents` service makes agent-prompt actions fail cleanly).
|
|
111
|
+
* @param createChatFn - Optional async `({ workspaceId, title }) => Promise<{ sessionId }>`
|
|
112
|
+
* used by the `chatPolicy: "new-chat"` / `"new-per-run"` modes.
|
|
104
113
|
* @returns Async `(action) => output` for the configured service executer.
|
|
105
114
|
*/
|
|
106
|
-
export function createExecuter(ctx) {
|
|
115
|
+
export function createExecuter(ctx, createChatFn) {
|
|
107
116
|
return async function executer(action) {
|
|
108
117
|
if (action.kind === "shell-command") {
|
|
109
118
|
return runShellCommand(action.payload);
|
|
110
119
|
}
|
|
111
120
|
if (action.kind === "agent-prompt") {
|
|
121
|
+
const agents = ctx.agents;
|
|
122
|
+
const policy = action.chatPolicy;
|
|
123
|
+
// new-per-run: create a fresh chat on EVERY run with a run-stamped title.
|
|
124
|
+
if (policy === "new-per-run" && createChatFn && action.chatWorkspaceId) {
|
|
125
|
+
const title = action.label.trim().length > 0
|
|
126
|
+
? `${action.label} · ${runStamp(Date.now())}`
|
|
127
|
+
: `Запланированное событие · ${runStamp(Date.now())}`;
|
|
128
|
+
const created = await createChatFn({ workspaceId: action.chatWorkspaceId, title });
|
|
129
|
+
const sessionId = created?.sessionId;
|
|
130
|
+
const agent = agents && sessionId ? agents.get(sessionId) : undefined;
|
|
131
|
+
if (!agent) throw new Error(`agent-prompt (new-per-run): chat "${sessionId ?? "(none)"}" is not a live agent`);
|
|
132
|
+
return deliverAgentPrompt(ctx, agent, action.payload);
|
|
133
|
+
}
|
|
112
134
|
const agent = resolveTargetAgent(ctx, action);
|
|
113
135
|
if (!agent) throw new Error(`agent-prompt action "${action.label}" has no target agent`);
|
|
114
136
|
return deliverAgentPrompt(ctx, agent, action.payload);
|
package/lib/index.js
CHANGED
|
@@ -24,6 +24,79 @@ export const name = "scheduled-actions";
|
|
|
24
24
|
*/
|
|
25
25
|
export const inject = ["agents"];
|
|
26
26
|
|
|
27
|
+
/** Latest user/auto session title from a live session's own durable events. */
|
|
28
|
+
function sessionTitleOf(session) {
|
|
29
|
+
try {
|
|
30
|
+
const events = session?.ownEvents?.();
|
|
31
|
+
if (!events) return undefined;
|
|
32
|
+
const titleEvent = [...events].reverse().find((e) => e?.type === "session/title");
|
|
33
|
+
return titleEvent?.data?.title ?? undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Live top-level chats (root agents) with a readable title for the picker. */
|
|
40
|
+
function listLiveChats(ctx) {
|
|
41
|
+
const agents = ctx.get ? ctx.get("agents") : undefined;
|
|
42
|
+
if (!agents || typeof agents.roots !== "function") return [];
|
|
43
|
+
return agents.roots().map((agent) => ({
|
|
44
|
+
id: agent.id,
|
|
45
|
+
title: sessionTitleOf(agent.session) ?? agent.id,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Durable workspaces available for creating a new chat in. */
|
|
50
|
+
function listWorkspaces(ctx) {
|
|
51
|
+
const registry = ctx.get ? ctx.get("workspaceRegistry") : undefined;
|
|
52
|
+
if (!registry || typeof registry.list !== "function") return [];
|
|
53
|
+
try {
|
|
54
|
+
return registry.list().map((w) => ({
|
|
55
|
+
id: w.id,
|
|
56
|
+
title: w.title ?? w.record?.title ?? w.path ?? w.id,
|
|
57
|
+
path: w.path ?? w.record?.path ?? "",
|
|
58
|
+
}));
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a new chat (session + agent) in the given workspace and give it a
|
|
66
|
+
* title, using the same host sessionController the web GUI calls. Returns the
|
|
67
|
+
* new session id, or throws when the service is unavailable / creation fails.
|
|
68
|
+
*/
|
|
69
|
+
async function createChat(ctx, payload) {
|
|
70
|
+
const sessionController = ctx.get ? ctx.get("sessionController") : undefined;
|
|
71
|
+
if (!sessionController || typeof sessionController.create !== "function") {
|
|
72
|
+
const err = new Error("scheduled-actions: sessionController unavailable (web app not mounted)");
|
|
73
|
+
err.code = "scheduled-actions/no-session-controller";
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
const workspaceId = payload?.workspaceId;
|
|
77
|
+
if (!workspaceId || typeof workspaceId !== "string") {
|
|
78
|
+
const err = new Error("scheduled-actions: workspaceId is required");
|
|
79
|
+
err.code = "gateway/bad-request";
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
const created = await sessionController.create({ workspaceId });
|
|
83
|
+
const sessionId = created?.sessionId;
|
|
84
|
+
if (!sessionId) throw new Error("scheduled-actions: session.create returned no sessionId");
|
|
85
|
+
const title = typeof payload?.title === "string" && payload.title.trim().length > 0
|
|
86
|
+
? payload.title.trim()
|
|
87
|
+
: undefined;
|
|
88
|
+
if (title !== undefined && typeof sessionController.rename === "function") {
|
|
89
|
+
try {
|
|
90
|
+
await sessionController.rename({ sessionId, title });
|
|
91
|
+
} catch (e) {
|
|
92
|
+
ctx.logger?.warn?.(
|
|
93
|
+
`scheduled-actions: chat "${sessionId}" created but rename to "${title}" failed: ${e && e.message ? e.message : String(e)}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { sessionId };
|
|
98
|
+
}
|
|
99
|
+
|
|
27
100
|
/** Default store path under the DSH home directory. */
|
|
28
101
|
function defaultStorePath() {
|
|
29
102
|
return join(homedir(), ".dsh", "scheduled-actions.json");
|
|
@@ -37,6 +110,7 @@ export class ScheduledActionsService {
|
|
|
37
110
|
#engine;
|
|
38
111
|
#logger;
|
|
39
112
|
#executer;
|
|
113
|
+
#chatProvisioner;
|
|
40
114
|
|
|
41
115
|
/**
|
|
42
116
|
* @param store - The action store.
|
|
@@ -65,6 +139,15 @@ export class ScheduledActionsService {
|
|
|
65
139
|
this.#executer = executer;
|
|
66
140
|
}
|
|
67
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Configure the chat provisioner used by `chatPolicy: "new-chat"`: create
|
|
144
|
+
* one dedicated chat for the event on its first run and remember its session
|
|
145
|
+
* as the action's target. Async `({ workspaceId, title }) => { sessionId }`.
|
|
146
|
+
*/
|
|
147
|
+
setChatProvisioner(provisioner) {
|
|
148
|
+
this.#chatProvisioner = provisioner;
|
|
149
|
+
}
|
|
150
|
+
|
|
68
151
|
/** All actions. */
|
|
69
152
|
list() {
|
|
70
153
|
return this.#store.list();
|
|
@@ -102,6 +185,9 @@ export class ScheduledActionsService {
|
|
|
102
185
|
const now = Date.now();
|
|
103
186
|
const from = current.lastRunAt ?? current.nextRunAt ?? undefined;
|
|
104
187
|
merged.nextRunAt = computeNextRun(merged.schedule, now, from);
|
|
188
|
+
// The occurrence moved on: forget any earlier «Позже» dismissal, so a
|
|
189
|
+
// genuinely missed later run can be reported again.
|
|
190
|
+
merged.dismissedNextRunAt = undefined;
|
|
105
191
|
}
|
|
106
192
|
await this.#store.put(merged);
|
|
107
193
|
this.#engine.requestDrive();
|
|
@@ -140,6 +226,19 @@ export class ScheduledActionsService {
|
|
|
140
226
|
let output = "";
|
|
141
227
|
let error;
|
|
142
228
|
try {
|
|
229
|
+
// chatPolicy "new-chat": create the one dedicated chat on the first run
|
|
230
|
+
// and remember its session as the action's target so later runs reuse it.
|
|
231
|
+
if (
|
|
232
|
+
action.kind === "agent-prompt"
|
|
233
|
+
&& action.chatPolicy === "new-chat"
|
|
234
|
+
&& !action.targetAgentId
|
|
235
|
+
&& this.#chatProvisioner
|
|
236
|
+
&& action.chatWorkspaceId
|
|
237
|
+
) {
|
|
238
|
+
const title = action.label.trim().length > 0 ? action.label.trim() : "Запланированное событие";
|
|
239
|
+
const created = await this.#chatProvisioner({ workspaceId: action.chatWorkspaceId, title });
|
|
240
|
+
if (created?.sessionId) action = { ...action, targetAgentId: created.sessionId };
|
|
241
|
+
}
|
|
143
242
|
output = await executer(action);
|
|
144
243
|
success = true;
|
|
145
244
|
} catch (err) {
|
|
@@ -153,6 +252,9 @@ export class ScheduledActionsService {
|
|
|
153
252
|
runCount: action.runCount + 1,
|
|
154
253
|
state: success ? "idle" : "failed",
|
|
155
254
|
updatedAt: finishedAt,
|
|
255
|
+
// This occurrence is now handled (ran, or the schedule moved past it):
|
|
256
|
+
// clear any earlier «Позже» dismissal so future misses can be reported.
|
|
257
|
+
dismissedNextRunAt: undefined,
|
|
156
258
|
};
|
|
157
259
|
if (action.schedule.type === "one-shot" && success) {
|
|
158
260
|
updated.state = "completed";
|
|
@@ -218,17 +320,37 @@ export class ScheduledActionsService {
|
|
|
218
320
|
return this.#run(action, runner);
|
|
219
321
|
}
|
|
220
322
|
|
|
221
|
-
/** Actions that are currently overdue
|
|
323
|
+
/** Actions that are currently overdue: enabled wall-clock actions whose
|
|
324
|
+
* scheduled time passed without running, minus occurrences the user already
|
|
325
|
+
* dismissed with «Позже». on-startup actions self-run at each host start and
|
|
326
|
+
* have no wall-clock deadline, so they are never reported as missed here. */
|
|
222
327
|
checkOverdue() {
|
|
223
328
|
const now = Date.now();
|
|
224
329
|
return this.#store.list().filter((action) => {
|
|
225
330
|
if (!action.enabled) return false;
|
|
226
|
-
if (action.schedule
|
|
331
|
+
if (action.schedule?.type === "on-startup") return false;
|
|
227
332
|
if (action.nextRunAt === null) return false;
|
|
333
|
+
if (action.dismissedNextRunAt === action.nextRunAt) return false;
|
|
228
334
|
return Date.parse(action.nextRunAt) <= now;
|
|
229
335
|
});
|
|
230
336
|
}
|
|
231
337
|
|
|
338
|
+
/** Remember the user's «Позже» for the current missed occurrence, so the
|
|
339
|
+
* overdue banner does not re-ask on every visit to the scheduler. The action
|
|
340
|
+
* itself is left untouched and stays in the list — it will only run when the
|
|
341
|
+
* user runs it manually (runNow) or its schedule is edited. */
|
|
342
|
+
async dismissOverdue(id) {
|
|
343
|
+
const current = this.#store.get(id);
|
|
344
|
+
if (current === undefined) return undefined;
|
|
345
|
+
const updated = {
|
|
346
|
+
...current,
|
|
347
|
+
dismissedNextRunAt: current.nextRunAt ?? null,
|
|
348
|
+
updatedAt: new Date().toISOString(),
|
|
349
|
+
};
|
|
350
|
+
await this.#store.put(updated);
|
|
351
|
+
return updated;
|
|
352
|
+
}
|
|
353
|
+
|
|
232
354
|
/**
|
|
233
355
|
* Whether an on-startup action should run on THIS host start, given its
|
|
234
356
|
* startMode and the last successful run.
|
|
@@ -299,12 +421,15 @@ export function apply(ctx, config) {
|
|
|
299
421
|
// Этап 4: wire the real payload executer (shell-command + agent-prompt).
|
|
300
422
|
// It reads `ctx.agents` for the agent-prompt delivery path; missing agents
|
|
301
423
|
// service makes only agent-prompt actions fail cleanly, shell still works.
|
|
424
|
+
const createChatBound = (payload) => createChat(ctx, payload);
|
|
302
425
|
if (typeof ctx.inject === "function") {
|
|
303
426
|
ctx.inject(["agents"], (agentCtx) => {
|
|
304
|
-
service.setExecuter(createExecuter(agentCtx));
|
|
427
|
+
service.setExecuter(createExecuter(agentCtx, createChatBound));
|
|
428
|
+
service.setChatProvisioner(createChatBound);
|
|
305
429
|
}, { label: "scheduled-actions.executer()" });
|
|
306
430
|
} else {
|
|
307
|
-
service.setExecuter(createExecuter(ctx));
|
|
431
|
+
service.setExecuter(createExecuter(ctx, createChatBound));
|
|
432
|
+
service.setChatProvisioner(createChatBound);
|
|
308
433
|
}
|
|
309
434
|
|
|
310
435
|
// Этап 4: register the management tools into every live root agent scope,
|
|
@@ -354,6 +479,10 @@ export function apply(ctx, config) {
|
|
|
354
479
|
case "toggle": return service.toggle(payload?.id);
|
|
355
480
|
case "runNow": return service.runNow(payload?.id);
|
|
356
481
|
case "checkOverdue": return service.checkOverdue();
|
|
482
|
+
case "dismissOverdue": return service.dismissOverdue(payload?.id);
|
|
483
|
+
case "listChats": return listLiveChats(ctx);
|
|
484
|
+
case "listWorkspaces": return listWorkspaces(ctx);
|
|
485
|
+
case "createChat": return createChat(ctx, payload);
|
|
357
486
|
default: {
|
|
358
487
|
const err = new Error(`scheduled-actions: unknown endpoint ${endpoint}`);
|
|
359
488
|
err.code = "gateway/not-found";
|
package/lib/scheduler.js
CHANGED
|
@@ -79,6 +79,9 @@ export class SchedulerEngine {
|
|
|
79
79
|
const target = Date.parse(action.nextRunAt);
|
|
80
80
|
if (!Number.isFinite(target)) continue;
|
|
81
81
|
if (target <= now) {
|
|
82
|
+
// The user dismissed this exact missed occurrence («Позже») and wants to
|
|
83
|
+
// run it manually — do not auto-dispatch it now or on later re-arms.
|
|
84
|
+
if (action.dismissedNextRunAt === action.nextRunAt) continue;
|
|
82
85
|
// Due now — dispatch asynchronously. `#busy` stays true so no other
|
|
83
86
|
// pass can start a second dispatch of the same (or another) action
|
|
84
87
|
// while this one is in flight; `#dispatch` releases the flag before
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-scheduled-actions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Host-level scheduled actions for DeepSeek Harness — create, verify, and run actions on a schedule (one-shot, interval, daily, weekly, cron, on-startup) with a JSON store and a Settings UI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cordis",
|