mixdog 0.9.24 → 0.9.26
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/package.json +2 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench-tmp.mjs +1 -1
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +166 -58
- package/src/tui/engine/agent-job-feed.mjs +42 -3
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/vendor/ink/build/display-width.js +5 -4
- package/src/runtime/shared/schedules-store.mjs +0 -82
|
@@ -157,22 +157,18 @@ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
|
|
|
157
157
|
// this global re-sort a multi-member chunk would emit oldest-first lines and
|
|
158
158
|
// break a strict newest-first contract (bench: recency-today).
|
|
159
159
|
const units = []
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
const COLLECT_CAP = recencyOrder ? Infinity : RECALL_LINE_CAP
|
|
167
|
-
let _capped = false
|
|
168
|
-
outer:
|
|
160
|
+
// No line-count cap here: the orchestrator enforces a global tool-output KB
|
|
161
|
+
// cap (builtin.mjs tool_output_token_limit), so recall need not self-truncate.
|
|
162
|
+
// recencyOrder still collects every unit first so the ts sort sees the full
|
|
163
|
+
// set; the default path emits in row order. Per-line body is bounded by a
|
|
164
|
+
// loose 8000-char safety cap so one giant pasted-log row can't monopolize
|
|
165
|
+
// the envelope.
|
|
169
166
|
for (const r of rows) {
|
|
170
167
|
// Collapsed near-duplicate (search path only — set by
|
|
171
168
|
// collapseNearDuplicateRows). Emit a one-line stub carrying its #id so an
|
|
172
169
|
// id-lookup follow-up can still fetch the full body; never rendered for
|
|
173
170
|
// id-lookup output (those rows never carry _dupStub).
|
|
174
171
|
if (r && r._dupStub) {
|
|
175
|
-
if (units.length >= COLLECT_CAP) { _capped = true; break }
|
|
176
172
|
units.push({ ts: Number(r.ts) || 0, text: `[${formatTs(r.ts)}] (near-duplicate of #${r._dupOf} — collapsed) #${r.id}` })
|
|
177
173
|
continue
|
|
178
174
|
}
|
|
@@ -182,14 +178,12 @@ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
|
|
|
182
178
|
// grouping artifact for retrieval — the caller wants the chunk
|
|
183
179
|
// content (cycle1 raw), not the cycle2-compressed summary.
|
|
184
180
|
for (const m of r.members) {
|
|
185
|
-
if (units.length >= COLLECT_CAP) { _capped = true; break outer }
|
|
186
181
|
const mTs = formatTs(m.ts)
|
|
187
182
|
const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
|
|
188
|
-
const content = cleanMemoryText(String(m.content ?? '')).slice(0,
|
|
183
|
+
const content = cleanMemoryText(String(m.content ?? '')).slice(0, 8000)
|
|
189
184
|
units.push({ ts: Number(m.ts) || 0, text: `[${mTs}] ${role}: ${content} #${m.id}` })
|
|
190
185
|
}
|
|
191
186
|
} else {
|
|
192
|
-
if (units.length >= COLLECT_CAP) { _capped = true; break }
|
|
193
187
|
// No chunks (root not yet chunked by cycle1, or orphan leaf): emit
|
|
194
188
|
// the row itself in the same shape. element/summary fall back to
|
|
195
189
|
// raw content when both are absent.
|
|
@@ -205,28 +199,25 @@ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
|
|
|
205
199
|
: ''
|
|
206
200
|
const body = element || summary
|
|
207
201
|
? `${element}${summary ? ' — ' + summary : ''}`
|
|
208
|
-
: cleanMemoryText(String(r.content ?? '')).slice(0,
|
|
202
|
+
: cleanMemoryText(String(r.content ?? '')).slice(0, 8000)
|
|
209
203
|
// Unchunked raw leaf (cycle1 hasn't classified it yet): mark it so
|
|
210
204
|
// callers can tell fresh-but-unprocessed rows from chunked memory.
|
|
211
205
|
const pendingMark = (r.is_root === 0 && r.chunk_root == null) ? ' [pending]' : ''
|
|
212
|
-
units.push({ ts: Number(r.ts) || 0, text: `[${ts}] ${rolePrefix}${body.slice(0,
|
|
206
|
+
units.push({ ts: Number(r.ts) || 0, text: `[${ts}] ${rolePrefix}${body.slice(0, 8000)}${pendingMark} #${r.id}` })
|
|
213
207
|
}
|
|
214
208
|
}
|
|
215
209
|
if (recencyOrder) {
|
|
216
210
|
// Array.sort is stable in V8, so units sharing the same ts keep their
|
|
217
211
|
// insertion (root/member) order; only cross-unit ts breaks are corrected.
|
|
218
212
|
units.sort((a, b) => b.ts - a.ts)
|
|
219
|
-
if (units.length > RECALL_LINE_CAP) { units.length = RECALL_LINE_CAP; _capped = true }
|
|
220
213
|
}
|
|
221
|
-
|
|
222
|
-
if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
|
|
223
|
-
return lines.join('\n')
|
|
214
|
+
return units.map((u) => u.text).join('\n')
|
|
224
215
|
}
|
|
225
216
|
|
|
226
217
|
// Search-result de-duplication. Within a SINGLE formatted result set, hybrid
|
|
227
218
|
// recall frequently returns several long rows that restate the same design in
|
|
228
219
|
// slightly different words (e.g. a root summary plus a chunk that paraphrases
|
|
229
|
-
// it). They each spend
|
|
220
|
+
// it). They each spend a large slice of the envelope on near-identical text.
|
|
230
221
|
// Cheap heuristic (no embeddings): normalize each row's body to word tokens,
|
|
231
222
|
// build 3-gram shingle sets, and measure containment overlap = |A∩B| /
|
|
232
223
|
// min(|A|,|B|) against already-kept rows. Rows arrive rank/date-ordered, so the
|
|
@@ -308,6 +299,31 @@ function shortSessionLabel(sid) {
|
|
|
308
299
|
return `…${s.slice(-16)}`
|
|
309
300
|
}
|
|
310
301
|
|
|
302
|
+
// Collect every ts in a session group (roots + their inline members) so the
|
|
303
|
+
// span header reflects the true activity window, not just the root ts.
|
|
304
|
+
function collectGroupTs(groupRows) {
|
|
305
|
+
const all = []
|
|
306
|
+
for (const r of groupRows) {
|
|
307
|
+
const t = Number(r?.ts)
|
|
308
|
+
if (Number.isFinite(t)) all.push(t)
|
|
309
|
+
if (Array.isArray(r?.members)) for (const m of r.members) {
|
|
310
|
+
const mt = Number(m?.ts)
|
|
311
|
+
if (Number.isFinite(mt)) all.push(mt)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return all
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n건)`; the end keeps the
|
|
318
|
+
// MM-DD prefix only when it falls on a different calendar day than the start.
|
|
319
|
+
function spanHeaderSuffix(minTs, maxTs, n) {
|
|
320
|
+
const min = formatTs(minTs) // "YYYY-MM-DD HH:mm"
|
|
321
|
+
const max = formatTs(maxTs)
|
|
322
|
+
const startPart = min.slice(5) // "MM-DD HH:mm"
|
|
323
|
+
const endPart = min.slice(0, 10) === max.slice(0, 10) ? max.slice(11) : max.slice(5)
|
|
324
|
+
return `(${startPart} ~ ${endPart}, ${n}건)`
|
|
325
|
+
}
|
|
326
|
+
|
|
311
327
|
// Session-grouped rendering for the GLOBAL query-less browse ("what did we
|
|
312
328
|
// work on recently") when the window spans multiple sessions. A flat ts-desc
|
|
313
329
|
// list interleaves sessions into one indistinguishable stream and lets one
|
|
@@ -320,7 +336,7 @@ function shortSessionLabel(sid) {
|
|
|
320
336
|
// group's lines are globally ts-desc: without it a chunk root's members (stored
|
|
321
337
|
// ts-ASC) interleave with raw rows and invert the visible timeline within a
|
|
322
338
|
// session (e.g. 04:33 rendered above 04:41).
|
|
323
|
-
export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false } = {}) {
|
|
339
|
+
export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false } = {}) {
|
|
324
340
|
if (!rows || rows.length === 0) return '(no results)'
|
|
325
341
|
const groups = new Map()
|
|
326
342
|
for (const r of rows) {
|
|
@@ -329,8 +345,30 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
|
|
|
329
345
|
if (!groups.has(key)) groups.set(key, [])
|
|
330
346
|
groups.get(key).push(r)
|
|
331
347
|
}
|
|
332
|
-
if (groups.size <= 1) return renderEntryLines(rows, { recencyOrder })
|
|
348
|
+
if (!spanHeaders && groups.size <= 1) return renderEntryLines(rows, { recencyOrder })
|
|
333
349
|
const current = String(currentSessionId || '').trim()
|
|
350
|
+
// period='last' session-grouped browse: activity-span headers over each
|
|
351
|
+
// group's body. No line budget — the orchestrator's global tool-output KB
|
|
352
|
+
// cap bounds total size; each group's body is already row-capped by the
|
|
353
|
+
// caller.
|
|
354
|
+
if (spanHeaders) {
|
|
355
|
+
const parts = []
|
|
356
|
+
for (const [sid, groupRows] of groups) {
|
|
357
|
+
const mark = current && sid === current ? ' (current)' : ''
|
|
358
|
+
const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
|
|
359
|
+
const tsAll = collectGroupTs(groupRows)
|
|
360
|
+
const suffix = tsAll.length
|
|
361
|
+
? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
|
|
362
|
+
: ` (${groupRows.length}건)`
|
|
363
|
+
parts.push(`## ${label}${mark}${suffix}`)
|
|
364
|
+
const bodyStr = renderEntryLines(groupRows, { recencyOrder })
|
|
365
|
+
const bodyLines = bodyStr === '(no results)'
|
|
366
|
+
? []
|
|
367
|
+
: bodyStr.split('\n')
|
|
368
|
+
for (const l of bodyLines) parts.push(l)
|
|
369
|
+
}
|
|
370
|
+
return parts.join('\n')
|
|
371
|
+
}
|
|
334
372
|
const parts = []
|
|
335
373
|
for (const [sid, groupRows] of groups) {
|
|
336
374
|
const mark = current && sid === current ? ' (current)' : ''
|
|
@@ -371,17 +371,31 @@ export function createSessionIngestRuntime({
|
|
|
371
371
|
// cap). Serialized on _rawEmbedFlushChain so bursts don't stack full
|
|
372
372
|
// backlog scans; the ~60s tick still sweeps whatever this misses.
|
|
373
373
|
if (insertedIds.length > 0) {
|
|
374
|
-
const
|
|
374
|
+
const runOwnFlush = () => flushRawEmbeddings(db, { limit: 200, ids: insertedIds })
|
|
375
375
|
.then((r) => {
|
|
376
376
|
if (r.attempted > 0) log(`[embed] post-ingest raw flush (own) attempted=${r.attempted} embedded=${r.embedded}\n`)
|
|
377
377
|
return r
|
|
378
378
|
})
|
|
379
379
|
.catch((err) => log(`[embed] post-ingest raw flush failed: ${err?.message || err}\n`))
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
380
|
+
// Clear/manual-compact path opts out (embedWait:false): those rows are
|
|
381
|
+
// about to be summarized away, so dense-search immediacy is pointless and
|
|
382
|
+
// the bounded wait would only delay compaction. Enqueue the flush onto
|
|
383
|
+
// _rawEmbedFlushChain (append, don't await) so clear-path ingest bursts
|
|
384
|
+
// stay serialized like the backlog sweep — never running concurrent raw
|
|
385
|
+
// flushes. All other callers keep the awaited (bounded) wait so a
|
|
386
|
+
// following recall sees the rows.
|
|
387
|
+
if (args.embedWait === false) {
|
|
388
|
+
_rawEmbedFlushChain = _rawEmbedFlushChain
|
|
389
|
+
.catch(() => {})
|
|
390
|
+
.then(runOwnFlush)
|
|
391
|
+
.catch(() => {})
|
|
392
|
+
} else {
|
|
393
|
+
let timer
|
|
394
|
+
await Promise.race([
|
|
395
|
+
runOwnFlush(),
|
|
396
|
+
new Promise((resolve) => { timer = setTimeout(resolve, INGEST_EMBED_WAIT_MS) }),
|
|
397
|
+
]).finally(() => clearTimeout(timer))
|
|
398
|
+
}
|
|
385
399
|
}
|
|
386
400
|
// Background backlog sweep — kicked, never awaited. Runs even when THIS
|
|
387
401
|
// call inserted 0 rows, so pre-existing backlog is not left waiting for
|
|
@@ -36,7 +36,7 @@ export const TOOL_DEFS = [
|
|
|
36
36
|
properties: {
|
|
37
37
|
query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Search text, or array for independent fan-out queries.' },
|
|
38
38
|
id: { anyOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' }, minItems: 1 }], description: 'Exact #id(s) from recall. Do not invent ids.' },
|
|
39
|
-
period: { type: 'string', description: "last (
|
|
39
|
+
period: { type: 'string', description: "last (recent sessions grouped, newest-active first; limit=session count [default 5], offset=session paging), Nm/Nh/Nd (rolling), today/yesterday/this_week/last_week, all, YYYY-MM-DD, YYYY-MM-DD~YYYY-MM-DD, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
|
|
40
40
|
limit: { type: 'number', description: 'Max entries.' },
|
|
41
41
|
offset: { type: 'number', description: 'Skip entries.' },
|
|
42
42
|
sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { detachedSpawnOpts } from './spawn-flags.mjs';
|
|
4
5
|
|
|
5
6
|
function positiveInt(value) {
|
|
6
7
|
const n = Number(value);
|
|
@@ -141,14 +142,13 @@ export function startChildGuardian({
|
|
|
141
142
|
forceGraceMs: Math.max(100, Math.floor(Number(forceGraceMs) || Number(graceMs) || 3000)),
|
|
142
143
|
}),
|
|
143
144
|
], {
|
|
144
|
-
detached: true,
|
|
145
145
|
stdio: 'ignore',
|
|
146
|
-
windowsHide: true,
|
|
147
146
|
env: {
|
|
148
147
|
PATH: process.env.PATH || '',
|
|
149
148
|
SystemRoot: process.env.SystemRoot || process.env.WINDIR || '',
|
|
150
149
|
WINDIR: process.env.WINDIR || process.env.SystemRoot || '',
|
|
151
150
|
},
|
|
151
|
+
...detachedSpawnOpts,
|
|
152
152
|
});
|
|
153
153
|
guardian.unref?.();
|
|
154
154
|
return { pid: guardian.pid || null, label, childPid: child };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { isWSL } from './wsl.mjs';
|
|
3
|
+
import { detachedSpawnOpts } from './spawn-flags.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Open a URL in the user's default browser. Best-effort and non-blocking.
|
|
@@ -29,7 +30,7 @@ function tryOpenCandidates(candidates, index) {
|
|
|
29
30
|
if (index >= candidates.length) return;
|
|
30
31
|
const [cmd, args] = candidates[index];
|
|
31
32
|
try {
|
|
32
|
-
const child = spawn(cmd, args, { stdio: 'ignore',
|
|
33
|
+
const child = spawn(cmd, args, { stdio: 'ignore', ...detachedSpawnOpts });
|
|
33
34
|
child.on('error', () => { tryOpenCandidates(candidates, index + 1); });
|
|
34
35
|
child.unref();
|
|
35
36
|
} catch {
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PG-backed schedules store — the single source of truth for registered
|
|
3
|
+
* schedules (schema `scheduler`, table `scheduler.schedules`).
|
|
4
|
+
*
|
|
5
|
+
* All schedule readers/writers (scheduler.mjs, config.mjs, channel-admin.mjs)
|
|
6
|
+
* go through this module. Legacy `<dataDir>/schedules/<name>/SCHEDULE.md`
|
|
7
|
+
* files are imported once by the migration hook in getDb and the directory is
|
|
8
|
+
* renamed to `schedules.migrated`; the old file-based schedules-store.mjs has
|
|
9
|
+
* been removed.
|
|
10
|
+
*
|
|
11
|
+
* DDL is idempotent and runs once on the first call per process. All queries
|
|
12
|
+
* fully-qualify `scheduler.schedules` so they are correct regardless of the
|
|
13
|
+
* connection search_path.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ensurePgInstance, withSchemaBootstrapLock } from '../memory/lib/pg/adapter.mjs';
|
|
17
|
+
import { resolvePluginData } from './plugin-paths.mjs';
|
|
18
|
+
import { readdirSync, readFileSync, renameSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { readMarkdownDocument } from './markdown-frontmatter.mjs';
|
|
21
|
+
|
|
22
|
+
const SCHEMA = 'scheduler';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Lazy connection + one-shot idempotent DDL (keyed per resolved dataDir).
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const _ready = new Map(); // dataDir → Promise<db>
|
|
29
|
+
|
|
30
|
+
const DDL = `
|
|
31
|
+
CREATE TABLE IF NOT EXISTS scheduler.schedules (
|
|
32
|
+
name text PRIMARY KEY,
|
|
33
|
+
description text NOT NULL DEFAULT '',
|
|
34
|
+
when_at timestamptz,
|
|
35
|
+
when_cron text,
|
|
36
|
+
timezone text,
|
|
37
|
+
target text NOT NULL CHECK (target IN ('channel','session')),
|
|
38
|
+
channel_id text,
|
|
39
|
+
model text,
|
|
40
|
+
prompt text NOT NULL,
|
|
41
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
42
|
+
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','done')),
|
|
43
|
+
last_fired_at timestamptz,
|
|
44
|
+
next_fire_at timestamptz,
|
|
45
|
+
deferred_until timestamptz,
|
|
46
|
+
skipped_until timestamptz,
|
|
47
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
48
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
49
|
+
CONSTRAINT schedules_when_xor CHECK ((when_at IS NOT NULL) <> (when_cron IS NOT NULL))
|
|
50
|
+
);
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
async function getDb(dataDir = resolvePluginData()) {
|
|
54
|
+
if (_ready.has(dataDir)) return _ready.get(dataDir);
|
|
55
|
+
const p = (async () => {
|
|
56
|
+
const { db, pool } = await ensurePgInstance(dataDir, { schema: SCHEMA });
|
|
57
|
+
// Serialize the CREATE TABLE across concurrent first-boot processes on the
|
|
58
|
+
// same cluster-global advisory lock the adapter uses for schema bootstrap,
|
|
59
|
+
// so racing first calls can't run the DDL simultaneously.
|
|
60
|
+
await withSchemaBootstrapLock(pool, () => db.exec(DDL));
|
|
61
|
+
await migrateLegacySchedules(db, dataDir);
|
|
62
|
+
return db;
|
|
63
|
+
})();
|
|
64
|
+
_ready.set(dataDir, p);
|
|
65
|
+
try {
|
|
66
|
+
return await p;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
_ready.delete(dataDir); // let the next call retry DDL after a transient failure
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// One-time legacy SCHEDULE.md migration (additive; runs once per dataDir on
|
|
75
|
+
// first getDb, right after DDL). Imports every `<dataDir>/schedules/<name>/
|
|
76
|
+
// SCHEDULE.md` not already present in the table (by name), using the same
|
|
77
|
+
// days->cron folding as the admin write path, then renames the directory to
|
|
78
|
+
// `schedules.migrated` (never deletes user data). Per-entry failures are
|
|
79
|
+
// isolated and never block store readiness.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
// Day-name / keyword -> cron day-of-week number (Sun=0 .. Sat=6).
|
|
83
|
+
const MIGRATE_DAY_TO_DOW = {
|
|
84
|
+
sun: 0, sunday: 0,
|
|
85
|
+
mon: 1, monday: 1,
|
|
86
|
+
tue: 2, tues: 2, tuesday: 2,
|
|
87
|
+
wed: 3, weds: 3, wednesday: 3,
|
|
88
|
+
thu: 4, thur: 4, thurs: 4, thursday: 4,
|
|
89
|
+
fri: 5, friday: 5,
|
|
90
|
+
sat: 6, saturday: 6,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function foldLegacyDaysIntoCron(cron, days) {
|
|
94
|
+
const parts = String(cron || '').trim().split(/\s+/).filter(Boolean);
|
|
95
|
+
if (parts.length !== 5 && parts.length !== 6) {
|
|
96
|
+
throw new Error(`invalid cron "${cron}"`);
|
|
97
|
+
}
|
|
98
|
+
const raw = String(days || '').trim().toLowerCase();
|
|
99
|
+
const dowIndex = parts.length - 1;
|
|
100
|
+
// days absent -> keep the cron's own day-of-week field ('0 9 * * 1' stays
|
|
101
|
+
// Monday-only). Only an explicit selector rewrites the dow field.
|
|
102
|
+
if (!raw) return parts.join(' ');
|
|
103
|
+
let dow;
|
|
104
|
+
if (raw === 'daily' || raw === 'everyday' || raw === 'every day') dow = '*';
|
|
105
|
+
else if (raw === 'weekday' || raw === 'weekdays') dow = '1-5';
|
|
106
|
+
else if (raw === 'weekend' || raw === 'weekends') dow = '0,6';
|
|
107
|
+
else {
|
|
108
|
+
const nums = raw.split(/[\s,]+/).filter(Boolean).map((t) => (
|
|
109
|
+
/^[0-6]$/.test(t) ? Number(t) : MIGRATE_DAY_TO_DOW[t]
|
|
110
|
+
));
|
|
111
|
+
if (nums.some((n) => n === undefined)) {
|
|
112
|
+
throw new Error(`days "${days}" is not a recognizable day selector`);
|
|
113
|
+
}
|
|
114
|
+
dow = nums.join(',');
|
|
115
|
+
}
|
|
116
|
+
parts[dowIndex] = dow;
|
|
117
|
+
return parts.join(' ');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function migrateLegacySchedules(db, dataDir) {
|
|
121
|
+
const dir = join(dataDir, 'schedules');
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
return; // no legacy schedules/ dir -> nothing to migrate
|
|
127
|
+
}
|
|
128
|
+
let imported = 0;
|
|
129
|
+
let skipped = 0;
|
|
130
|
+
const failed = [];
|
|
131
|
+
for (const ent of entries) {
|
|
132
|
+
if (!ent.isDirectory()) continue;
|
|
133
|
+
const name = ent.name;
|
|
134
|
+
try {
|
|
135
|
+
const { rows } = await db.query('SELECT 1 FROM scheduler.schedules WHERE name = $1', [name]);
|
|
136
|
+
if (rows.length) { skipped++; continue; }
|
|
137
|
+
let md;
|
|
138
|
+
try { md = readFileSync(join(dir, name, 'SCHEDULE.md'), 'utf8'); }
|
|
139
|
+
catch { skipped++; continue; }
|
|
140
|
+
const { frontmatter, body } = readMarkdownDocument(md);
|
|
141
|
+
const cron = foldLegacyDaysIntoCron(frontmatter.time, frontmatter.days);
|
|
142
|
+
const channel = String(frontmatter.channel || '').trim();
|
|
143
|
+
const enabled = frontmatter.enabled !== 'false' && frontmatter.enabled !== false;
|
|
144
|
+
await db.query(
|
|
145
|
+
`INSERT INTO scheduler.schedules
|
|
146
|
+
(name, description, when_cron, timezone, target, channel_id, model, prompt, enabled)
|
|
147
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
148
|
+
ON CONFLICT (name) DO NOTHING`,
|
|
149
|
+
[
|
|
150
|
+
name,
|
|
151
|
+
String(frontmatter.description || '').trim(),
|
|
152
|
+
cron,
|
|
153
|
+
frontmatter.timezone ? String(frontmatter.timezone).trim() : null,
|
|
154
|
+
channel ? 'channel' : 'session',
|
|
155
|
+
channel || null,
|
|
156
|
+
frontmatter.model ? String(frontmatter.model).trim() : null,
|
|
157
|
+
String(body || '').trim(),
|
|
158
|
+
enabled,
|
|
159
|
+
],
|
|
160
|
+
);
|
|
161
|
+
imported++;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
failed.push(name);
|
|
164
|
+
console.error(`[schedules] migration failed for "${name}": ${err?.message || err}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (failed.length) {
|
|
168
|
+
// Some entries failed to import. Leave schedules/ in place (do NOT rename)
|
|
169
|
+
// so the next boot retries them; already-imported entries are skipped by
|
|
170
|
+
// the name check above, so retry is idempotent.
|
|
171
|
+
console.error(`[schedules] migrated ${imported} legacy schedule(s), ${failed.length} failed (${failed.join(', ')}); leaving schedules/ for retry`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
let target = `${dir}.migrated`;
|
|
176
|
+
if (existsSync(target)) target = `${dir}.migrated-${Date.now()}`;
|
|
177
|
+
renameSync(dir, target);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(`[schedules] migrated ${imported} legacy schedule(s) but could not rename schedules/: ${err?.message || err}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
console.error(`[schedules] migrated ${imported} legacy schedule(s) (${skipped} skipped); renamed schedules/ -> schedules.migrated`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Row <-> def mapping
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function rowToDef(row) {
|
|
190
|
+
if (!row) return null;
|
|
191
|
+
return {
|
|
192
|
+
name: row.name,
|
|
193
|
+
description: row.description,
|
|
194
|
+
whenAt: row.when_at,
|
|
195
|
+
whenCron: row.when_cron,
|
|
196
|
+
timezone: row.timezone,
|
|
197
|
+
target: row.target,
|
|
198
|
+
channelId: row.channel_id,
|
|
199
|
+
model: row.model,
|
|
200
|
+
prompt: row.prompt,
|
|
201
|
+
enabled: row.enabled,
|
|
202
|
+
status: row.status,
|
|
203
|
+
lastFiredAt: row.last_fired_at,
|
|
204
|
+
nextFireAt: row.next_fire_at,
|
|
205
|
+
deferredUntil: row.deferred_until,
|
|
206
|
+
skippedUntil: row.skipped_until,
|
|
207
|
+
createdAt: row.created_at,
|
|
208
|
+
updatedAt: row.updated_at,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Public API
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export async function listSchedules({ dataDir } = {}) {
|
|
219
|
+
const db = await getDb(dataDir);
|
|
220
|
+
const { rows } = await db.query(`SELECT ${COLS} FROM scheduler.schedules ORDER BY name`);
|
|
221
|
+
return rows.map(rowToDef);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function getSchedule(name, { dataDir } = {}) {
|
|
225
|
+
const db = await getDb(dataDir);
|
|
226
|
+
const { rows } = await db.query(`SELECT ${COLS} FROM scheduler.schedules WHERE name = $1`, [name]);
|
|
227
|
+
return rowToDef(rows[0]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Insert-or-replace a schedule by name. Exactly one of `whenAt`/`whenCron`
|
|
232
|
+
* must be provided (enforced by the table's XOR CHECK constraint).
|
|
233
|
+
*/
|
|
234
|
+
export async function upsertSchedule(def, { dataDir } = {}) {
|
|
235
|
+
if (!def || !def.name) throw new Error('upsertSchedule: def.name is required');
|
|
236
|
+
if (!def.prompt) throw new Error('upsertSchedule: def.prompt is required');
|
|
237
|
+
const db = await getDb(dataDir);
|
|
238
|
+
const params = [
|
|
239
|
+
def.name,
|
|
240
|
+
def.description ?? '',
|
|
241
|
+
def.whenAt ?? null,
|
|
242
|
+
def.whenCron ?? null,
|
|
243
|
+
def.timezone ?? null,
|
|
244
|
+
def.target,
|
|
245
|
+
def.channelId ?? null,
|
|
246
|
+
def.model ?? null,
|
|
247
|
+
def.prompt,
|
|
248
|
+
def.enabled ?? true,
|
|
249
|
+
def.status ?? 'active',
|
|
250
|
+
def.nextFireAt ?? null,
|
|
251
|
+
];
|
|
252
|
+
const { rows } = await db.query(
|
|
253
|
+
`INSERT INTO scheduler.schedules
|
|
254
|
+
(name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, next_fire_at)
|
|
255
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
|
256
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
257
|
+
description = EXCLUDED.description,
|
|
258
|
+
when_at = EXCLUDED.when_at,
|
|
259
|
+
when_cron = EXCLUDED.when_cron,
|
|
260
|
+
timezone = EXCLUDED.timezone,
|
|
261
|
+
target = EXCLUDED.target,
|
|
262
|
+
channel_id = EXCLUDED.channel_id,
|
|
263
|
+
model = EXCLUDED.model,
|
|
264
|
+
prompt = EXCLUDED.prompt,
|
|
265
|
+
enabled = EXCLUDED.enabled,
|
|
266
|
+
next_fire_at = EXCLUDED.next_fire_at,
|
|
267
|
+
-- Redefinition clears stale runtime state and reactivates so the
|
|
268
|
+
-- re-registered schedule is due again in listDue.
|
|
269
|
+
status = 'active',
|
|
270
|
+
deferred_until = NULL,
|
|
271
|
+
skipped_until = NULL,
|
|
272
|
+
last_fired_at = NULL,
|
|
273
|
+
updated_at = now()
|
|
274
|
+
RETURNING ${COLS}`,
|
|
275
|
+
params,
|
|
276
|
+
);
|
|
277
|
+
return rowToDef(rows[0]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function deleteSchedule(name, { dataDir } = {}) {
|
|
281
|
+
const db = await getDb(dataDir);
|
|
282
|
+
const { rowCount } = await db.query(`DELETE FROM scheduler.schedules WHERE name = $1`, [name]);
|
|
283
|
+
return rowCount > 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function setEnabled(name, enabled, { dataDir } = {}) {
|
|
287
|
+
const db = await getDb(dataDir);
|
|
288
|
+
const { rows } = await db.query(
|
|
289
|
+
`UPDATE scheduler.schedules SET enabled = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
290
|
+
[name, !!enabled],
|
|
291
|
+
);
|
|
292
|
+
return rowToDef(rows[0]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function markFired(name, ts = new Date(), { dataDir } = {}) {
|
|
296
|
+
const db = await getDb(dataDir);
|
|
297
|
+
const { rows } = await db.query(
|
|
298
|
+
`UPDATE scheduler.schedules SET last_fired_at = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
299
|
+
[name, ts],
|
|
300
|
+
);
|
|
301
|
+
return rowToDef(rows[0]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function markDone(name, { dataDir } = {}) {
|
|
305
|
+
const db = await getDb(dataDir);
|
|
306
|
+
const { rows } = await db.query(
|
|
307
|
+
`UPDATE scheduler.schedules SET status = 'done', next_fire_at = NULL, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
308
|
+
[name],
|
|
309
|
+
);
|
|
310
|
+
return rowToDef(rows[0]);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function setDeferred(name, untilTs, { dataDir } = {}) {
|
|
314
|
+
const db = await getDb(dataDir);
|
|
315
|
+
const { rows } = await db.query(
|
|
316
|
+
`UPDATE scheduler.schedules SET deferred_until = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
317
|
+
[name, untilTs ?? null],
|
|
318
|
+
);
|
|
319
|
+
return rowToDef(rows[0]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function setSkippedUntil(name, ts, { dataDir } = {}) {
|
|
323
|
+
const db = await getDb(dataDir);
|
|
324
|
+
const { rows } = await db.query(
|
|
325
|
+
`UPDATE scheduler.schedules SET skipped_until = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
326
|
+
[name, ts ?? null],
|
|
327
|
+
);
|
|
328
|
+
return rowToDef(rows[0]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Schedules that are eligible to fire at `now`: active, enabled, with a
|
|
333
|
+
* next_fire_at at/before `now`, and not currently deferred or skipped past
|
|
334
|
+
* `now`.
|
|
335
|
+
*/
|
|
336
|
+
export async function listDue(now = new Date(), { dataDir } = {}) {
|
|
337
|
+
const db = await getDb(dataDir);
|
|
338
|
+
const { rows } = await db.query(
|
|
339
|
+
`SELECT ${COLS} FROM scheduler.schedules
|
|
340
|
+
WHERE status = 'active'
|
|
341
|
+
AND enabled = true
|
|
342
|
+
AND next_fire_at IS NOT NULL
|
|
343
|
+
AND next_fire_at <= $1
|
|
344
|
+
AND (deferred_until IS NULL OR deferred_until <= $1)
|
|
345
|
+
AND (skipped_until IS NULL OR skipped_until <= $1)
|
|
346
|
+
ORDER BY next_fire_at`,
|
|
347
|
+
[now],
|
|
348
|
+
);
|
|
349
|
+
return rows.map(rowToDef);
|
|
350
|
+
}
|