@ottttto/dsh-scheduled-send 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.
@@ -0,0 +1,97 @@
1
+ // Host-side HTTP routes for the browser client. The web GUI cannot subscribe
2
+ // to host cordis events directly; the browser client polls GET state and
3
+ // calls POST/DELETE for scheduling. Payloads are display-safe only (no
4
+ // secrets ever leave the host).
5
+ //
6
+ // GET /plugin-data/dsh-scheduled-send/state → {now, tasks[]}
7
+ // POST /plugin-data/dsh-scheduled-send/schedule → create {content, sendAt, conversationId}
8
+ // DELETE /plugin-data/dsh-scheduled-send/schedule?id=… → cancel
9
+ //
10
+ // The model-switch feature (popover dropdown, model-selected confirm route,
11
+ // pending-switch payload) has been REMOVED. A legacy POST body containing a
12
+ // `model` field is accepted and ignored.
13
+
14
+ export const STATE_PATH = '/plugin-data/dsh-scheduled-send/state';
15
+ export const SCHEDULE_PATH = '/plugin-data/dsh-scheduled-send/schedule';
16
+
17
+ /**
18
+ * Register the routes on the host webServer.
19
+ * @param {object} webServer ctx.webServer (dsh-host-webserver service)
20
+ * @param {object} cache ctx.scheduledSend state: {scheduler, now}
21
+ * @returns {() => void} disposer
22
+ */
23
+ export function registerScheduledSendRoutes(webServer, cache) {
24
+ const now = () => (typeof cache.now === 'function' ? cache.now() : Date.now());
25
+ const sendJson = (res, status, payload) => {
26
+ // dsh-host-webserver contract: the handler owns the raw node response —
27
+ // it must writeHead/end itself; returning an object writes nothing.
28
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
29
+ res.end(JSON.stringify(payload));
30
+ };
31
+ const readBody = async (req) => {
32
+ let text = '';
33
+ for await (const chunk of req || []) text += chunk;
34
+ if (!text) return {};
35
+ try { return JSON.parse(text); } catch { return null; }
36
+ };
37
+
38
+ const disposers = [];
39
+
40
+ // GET state (?conversationId= filters tasks to that conversation)
41
+ disposers.push(webServer.register({
42
+ kind: 'exact',
43
+ path: STATE_PATH,
44
+ handler: async (req = {}, res) => {
45
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
46
+ sendJson(res, 405, { error: 'method not allowed' });
47
+ return;
48
+ }
49
+ const cid = new URL(req.url ?? '/', 'http://x').searchParams.get('conversationId');
50
+ const own = (it) => !cid || it?.conversationId === cid;
51
+ sendJson(res, 200, {
52
+ now: now(),
53
+ tasks: (cache.scheduler?.list?.() ?? []).filter(own),
54
+ });
55
+ },
56
+ }));
57
+
58
+ // POST/DELETE schedule
59
+ disposers.push(webServer.register({
60
+ kind: 'exact',
61
+ path: SCHEDULE_PATH,
62
+ handler: async (req = {}, res) => {
63
+ const method = (req.method || 'GET').toUpperCase();
64
+ if (method !== 'POST' && method !== 'DELETE') {
65
+ sendJson(res, 405, { error: 'method not allowed' });
66
+ return;
67
+ }
68
+ if (method === 'DELETE') {
69
+ const id = new URL(req.url ?? '/', 'http://x').searchParams.get('id');
70
+ if (!id) { sendJson(res, 400, { error: '缺少 id 参数' }); return; }
71
+ const ok = await cache.scheduler?.cancel?.(id);
72
+ sendJson(res, ok ? 200 : 404, ok ? { cancelled: id } : { error: '任务不存在或已发送' });
73
+ return;
74
+ }
75
+ const body = await readBody(req);
76
+ if (body === null || typeof body !== 'object') { sendJson(res, 400, { error: '请求体必须是 JSON 对象' }); return; }
77
+ const content = typeof body.content === 'string' ? body.content : '';
78
+ const sendAt = body.sendAt;
79
+ const conversationId = typeof body.conversationId === 'string' ? body.conversationId : '';
80
+ if (!content.trim()) { sendJson(res, 400, { error: '定时内容不能为空' }); return; }
81
+ if (typeof sendAt !== 'number' || !Number.isFinite(sendAt) || sendAt <= now()) {
82
+ sendJson(res, 400, { error: 'sendAt 必须是未来的时间戳(epoch ms)' });
83
+ return;
84
+ }
85
+ if (!conversationId) { sendJson(res, 400, { error: '缺少 conversationId(会话绑定)' }); return; }
86
+ // body.model intentionally ignored (model switching removed)
87
+ try {
88
+ const task = await cache.scheduler.schedule({ content, sendAt, conversationId });
89
+ sendJson(res, 200, { task });
90
+ } catch (err) {
91
+ sendJson(res, 400, { error: String(err?.message || err) });
92
+ }
93
+ },
94
+ }));
95
+
96
+ return () => { for (const d of disposers) d(); };
97
+ }
package/src/index.js ADDED
@@ -0,0 +1,75 @@
1
+ // dsh-scheduled-send — host-side cordis plugin entry.
2
+ // Assembles the scheduled-send service: persistent task queue (scheduler),
3
+ // due-time delivery into the ORIGINAL conversation as a normal user bubble
4
+ // (delivery), and the host HTTP routes the browser client talks to
5
+ // (host-routes). Model switching has been REMOVED: legacy tasks carrying a
6
+ // `model` field deliver normally with the field ignored.
7
+
8
+ import { createScheduler } from './scheduler.js';
9
+ import { installAgentTracking, createFollowupDelivery } from './delivery.js';
10
+ import { registerScheduledSendRoutes } from './host-routes.js';
11
+
12
+ /**
13
+ * Cordis plugin metadata: the loader reads the named `inject` export to wire
14
+ * required host services into ctx before apply() runs. Without it, reading
15
+ * `ctx.webServer` throws "cannot get property without inject" at boot.
16
+ */
17
+ export const inject = ['webServer'];
18
+ export const name = 'dsh-scheduled-send';
19
+
20
+ /**
21
+ * @param {object} ctx host plugin context
22
+ * @param {object} [config] {dataDir} overrides
23
+ * @param {object} [deps] test hooks: trackAgents(ctx), createUserMessage(spec),
24
+ * deliverDue(item), clock, timers.
25
+ */
26
+ export async function apply(ctx, config = {}, deps = {}) {
27
+ const dataDir = config.dataDir || `${process.env.HOME}/.dsh/dsh-scheduled-send`;
28
+ const clock = deps.clock || config.clock || { now: () => Date.now() };
29
+ const timers = deps.timers || config.timers || undefined;
30
+
31
+ // agent tracking: agent.id IS the conversation id, so tasks bind back to
32
+ // their original conversation even across restarts (会话绑定+补发).
33
+ const tracking = deps.trackAgents ? deps.trackAgents(ctx) : installAgentTracking(ctx);
34
+
35
+ const cache = {
36
+ dataDir,
37
+ now: () => clock.now(),
38
+ };
39
+
40
+ const deliverDue = deps.deliverDue ?? createFollowupDelivery({
41
+ tracking,
42
+ createUserMessage: deps.createUserMessage,
43
+ });
44
+
45
+ cache.scheduler = await createScheduler({
46
+ dataDir,
47
+ clock,
48
+ timers,
49
+ deliver: deliverDue,
50
+ });
51
+ cache.agentTracking = tracking;
52
+
53
+ // cordis Context is a proxy: arbitrary properties must be registered as
54
+ // services via ctx.provide() — a bare `ctx.scheduledSend = cache` throws
55
+ // "cannot set property without provide" at plugin load time.
56
+ if (typeof ctx.provide === 'function') ctx.provide('scheduledSend', cache);
57
+ else ctx.scheduledSend = cache; // test/plain-object contexts
58
+
59
+ if (ctx.webServer?.register) {
60
+ cache.disposeRoutes = registerScheduledSendRoutes(ctx.webServer, cache);
61
+ } else {
62
+ ctx.logger?.warn?.('[dsh-scheduled-send] webServer 不可用,客户端路由未注册');
63
+ }
64
+
65
+ ctx.on?.('dispose', () => {
66
+ cache.scheduler?.dispose?.();
67
+ tracking.dispose?.();
68
+ cache.disposeRoutes?.();
69
+ });
70
+
71
+ // cordis treats apply()'s return value as an effect disposer: a plain
72
+ // object here throws "TypeError: Invalid effect" at boot. Cleanup is
73
+ // registered via ctx.on('dispose'); the state is exposed as the
74
+ // 'scheduledSend' service above. Return nothing.
75
+ }
@@ -0,0 +1,146 @@
1
+ // Scheduled sending (定时发送): persistent task queue in <dataDir>/tasks.json.
2
+ // Each task carries {content, sendAt, conversationId, model?} and fires via
3
+ // the deliver callback at sendAt. The queue survives restarts (restored +
4
+ // re-armed; overdue tasks are re-delivered to their ORIGINAL conversation),
5
+ // NO_LIVE_AGENT failures keep the original due time so the task is resent as
6
+ // soon as the session reappears (恢复后补发), other failures retry with
7
+ // bounded backoff. Clock and timers are injectable for tests.
8
+
9
+ import { fs } from './deps.js';
10
+
11
+ function queuePath(dataDir) {
12
+ return `${dataDir}/tasks.json`;
13
+ }
14
+
15
+ /**
16
+ * Create the scheduler.
17
+ * @param {object} opts
18
+ * @param {string} opts.dataDir persistence directory
19
+ * @param {{now:()=>number}} [opts.clock] injectable clock (default Date)
20
+ * @param {{setTimeoutAt:(fn,atMs)=>id, clearTimeout:(id)=>void}} [opts.timers]
21
+ * injectable timer queue; defaults to global setTimeout/clearTimeout
22
+ * @param {(item:object)=>Promise<void>} opts.deliver deliver one due task
23
+ */
24
+ export async function createScheduler({ dataDir, clock = { now: () => Date.now() }, timers = defaultTimers(), deliver } = {}) {
25
+ if (typeof deliver !== 'function') throw new Error('createScheduler: deliver callback required');
26
+ await fs.mkdir(dataDir, { recursive: true });
27
+ const path = queuePath(dataDir);
28
+
29
+ // restore persisted queue (best-effort)
30
+ let queue = [];
31
+ try {
32
+ const parsed = JSON.parse(await fs.readFile(path, 'utf8'));
33
+ if (Array.isArray(parsed.items)) queue = parsed.items.filter((it) => it && it.id && typeof it.sendAt === 'number');
34
+ } catch {
35
+ /* missing/corrupt → start empty */
36
+ }
37
+
38
+ let seq = queue.reduce((m, it) => Math.max(m, Number(String(it.id).split('-')[1]) || 0), 0);
39
+ let armed = null; // { timerId }
40
+
41
+ const persist = async () => {
42
+ await fs.writeFile(path, JSON.stringify({ items: queue }, null, 2) + '\n', 'utf8');
43
+ };
44
+
45
+ const nextDue = () => queue.reduce((m, it) => (m === null || it.sendAt < m ? it.sendAt : m), null);
46
+
47
+ const fireDue = async () => {
48
+ const now = clock.now();
49
+ // fire in sendAt order (ascending)
50
+ const due = queue.filter((it) => it.sendAt <= now).sort((a, b) => a.sendAt - b.sendAt);
51
+ for (const item of due) {
52
+ queue = queue.filter((it) => it.id !== item.id); // remove first; re-queue on failure
53
+ try {
54
+ await deliver(item);
55
+ } catch (err) {
56
+ item.attempts = (item.attempts || 0) + 1;
57
+ item.lastError = String(err && err.message);
58
+ if (err && err.code === 'NO_LIVE_AGENT') {
59
+ // target session not live yet: keep the ORIGINAL due time so the
60
+ // task is resent as soon as the session appears (恢复后补发), not
61
+ // delayed by delivery backoff.
62
+ } else {
63
+ // keep for retry: re-add with backoff pushed due time
64
+ item.sendAt = Math.max(clock.now(), now) + Math.min(60_000 * 2 ** (item.attempts - 1), 300_000);
65
+ }
66
+ queue.push(item);
67
+ }
68
+ }
69
+ if (due.length) await persist().catch(() => {});
70
+ arm();
71
+ };
72
+
73
+ const arm = () => {
74
+ if (armed) {
75
+ timers.clearTimeout(armed.timerId);
76
+ armed = null;
77
+ }
78
+ const due = nextDue();
79
+ if (due === null) return;
80
+ const delay = Math.max(0, due - clock.now());
81
+ const timerId = timers.setTimeoutAt(() => {
82
+ armed = null;
83
+ return fireDue(); // return the promise so injectable timer queues can await
84
+ }, clock.now() + delay);
85
+ armed = { timerId };
86
+ };
87
+
88
+ arm();
89
+ await persist().catch(() => {}); // ensure the file exists after first create
90
+
91
+ return {
92
+ /** Schedule a task. sendAt (epoch ms) is required; default is computed by the caller. */
93
+ async schedule({ content, sendAt, conversationId, model, meta }) {
94
+ if (typeof content !== 'string' || !content.trim()) throw new Error('定时内容不能为空');
95
+ if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) throw new Error('sendAt 必须是时间戳(epoch ms)');
96
+ const item = {
97
+ id: `task-${++seq}-${sendAt}`,
98
+ content,
99
+ sendAt,
100
+ conversationId: conversationId || null,
101
+ model: model || null,
102
+ meta: meta || null,
103
+ createdAt: clock.now(),
104
+ attempts: 0,
105
+ };
106
+ queue.push(item);
107
+ await persist();
108
+ arm();
109
+ return item;
110
+ },
111
+ /** Cancel a pending task before it fires. Returns true when removed. */
112
+ async cancel(id) {
113
+ const before = queue.length;
114
+ queue = queue.filter((it) => it.id !== id);
115
+ if (queue.length !== before) {
116
+ await persist();
117
+ arm();
118
+ return true;
119
+ }
120
+ return false;
121
+ },
122
+ /** Pending tasks sorted by sendAt ascending (display order). */
123
+ list() {
124
+ return [...queue].sort((a, b) => a.sendAt - b.sendAt).map((it) => ({ ...it }));
125
+ },
126
+ /** Manual tick (for tests / external loops). */
127
+ tick: fireDue,
128
+ async dispose() {
129
+ if (armed) timers.clearTimeout(armed.timerId);
130
+ armed = null;
131
+ },
132
+ };
133
+ }
134
+
135
+ function defaultTimers() {
136
+ return {
137
+ // setTimeoutAt receives an ABSOLUTE epoch-ms target (same contract as the
138
+ // injectable test timers); convert to a relative delay for real setTimeout.
139
+ setTimeoutAt: (fn, atMs) => {
140
+ const t = setTimeout(fn, Math.max(0, atMs - Date.now()));
141
+ t.unref?.();
142
+ return t;
143
+ },
144
+ clearTimeout: (t) => clearTimeout(t),
145
+ };
146
+ }