openclaw-scheduler 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/AGENTS.md +302 -0
- package/BEST-PRACTICES.md +506 -0
- package/CHANGELOG.md +82 -0
- package/CODE_OF_CONDUCT.md +22 -0
- package/CONTEXT.md +26 -0
- package/CONTRIBUTING.md +73 -0
- package/IMPLEMENTATION_SPEC.md +170 -0
- package/INSTALL-ADDITIONAL-HOST.md +333 -0
- package/INSTALL-LINUX.md +419 -0
- package/INSTALL-WINDOWS.md +305 -0
- package/INSTALL.md +364 -0
- package/JOB-QUICK-REF.md +222 -0
- package/LICENSE +21 -0
- package/QUICK-START.md +256 -0
- package/README.md +2170 -0
- package/SECURITY.md +34 -0
- package/UNINSTALL.md +129 -0
- package/UPGRADING.md +436 -0
- package/agents.js +67 -0
- package/approval.js +107 -0
- package/backup.js +390 -0
- package/bin/openclaw-scheduler.js +138 -0
- package/cli.js +1083 -0
- package/db.js +122 -0
- package/dispatch/529-recovery.mjs +204 -0
- package/dispatch/README.md +372 -0
- package/dispatch/config.example.json +24 -0
- package/dispatch/deliver-watcher.sh +57 -0
- package/dispatch/hooks.mjs +171 -0
- package/dispatch/index.mjs +1836 -0
- package/dispatch/watcher.mjs +1396 -0
- package/dispatch-queue.js +112 -0
- package/dispatcher-approvals.js +96 -0
- package/dispatcher-delivery.js +43 -0
- package/dispatcher-maintenance.js +242 -0
- package/dispatcher-shell.js +29 -0
- package/dispatcher-strategies.js +1280 -0
- package/dispatcher-utils.js +81 -0
- package/dispatcher.js +855 -0
- package/docs/adr-schedule-ownership.md +73 -0
- package/docs/gateway-contract.md +904 -0
- package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
- package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
- package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
- package/docs/trust-architecture.md +266 -0
- package/gateway.js +473 -0
- package/idempotency.js +119 -0
- package/index.d.ts +864 -0
- package/index.js +17 -0
- package/jobs.js +1224 -0
- package/messages.js +357 -0
- package/migrate-consolidate.js +694 -0
- package/migrate.js +125 -0
- package/package.json +130 -0
- package/paths.js +79 -0
- package/prompt-context.js +94 -0
- package/retrieval.js +176 -0
- package/runs.js +270 -0
- package/scheduler-schema.js +101 -0
- package/schema.sql +480 -0
- package/scripts/dispatch-cli-utils.mjs +65 -0
- package/scripts/inbox-consumer.mjs +288 -0
- package/scripts/stuck-detector.sh +18 -0
- package/scripts/stuck-run-detector.mjs +333 -0
- package/scripts/telegram-webhook-check.mjs +238 -0
- package/setup.mjs +724 -0
- package/shell-result.js +214 -0
- package/task-tracker.js +300 -0
- package/team-adapter.js +335 -0
- package/v02-runtime.js +599 -0
package/migrate.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Migrate existing OpenClaw cron jobs.json -> SQLite scheduler
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { initDb } from './db.js';
|
|
6
|
+
import { createJob, listJobs } from './jobs.js';
|
|
7
|
+
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
const JOBS_JSON = process.env.OPENCLAW_JOBS_JSON
|
|
11
|
+
|| join(process.env.HOME || homedir(), '.openclaw/cron/jobs.json');
|
|
12
|
+
|
|
13
|
+
function cronFromSchedule(schedule) {
|
|
14
|
+
// OpenClaw supports: cron (expr), every (everyMs), at (one-shot ISO)
|
|
15
|
+
if (schedule.kind === 'cron') {
|
|
16
|
+
return { cron: schedule.expr, tz: schedule.tz || 'UTC' };
|
|
17
|
+
}
|
|
18
|
+
if (schedule.kind === 'every') {
|
|
19
|
+
// Convert interval to approximate cron. everyMs -> minutes
|
|
20
|
+
if (schedule.everyMs < 60000) {
|
|
21
|
+
console.warn(` WARN: ${schedule.everyMs}ms interval rounded up to 1 minute (cron minimum)`);
|
|
22
|
+
}
|
|
23
|
+
const mins = Math.max(1, Math.round(schedule.everyMs / 60000));
|
|
24
|
+
if (mins < 60) return { cron: `*/${mins} * * * *`, tz: schedule.tz || 'UTC' };
|
|
25
|
+
const hours = Math.max(1, Math.round(mins / 60));
|
|
26
|
+
const remaining = mins % 60;
|
|
27
|
+
const clampedHours = Math.min(hours, 23);
|
|
28
|
+
if (hours > 23) {
|
|
29
|
+
console.warn(` WARN: ${schedule.everyMs}ms interval exceeds 23 hours; clamping to */23 hours`);
|
|
30
|
+
}
|
|
31
|
+
if (remaining !== 0) {
|
|
32
|
+
console.warn(` WARN: ${schedule.everyMs}ms interval (${mins}min) cannot be represented exactly in standard cron; approximating to every ${clampedHours} hour(s)`);
|
|
33
|
+
}
|
|
34
|
+
return { cron: `0 */${clampedHours} * * *`, tz: schedule.tz || 'UTC' };
|
|
35
|
+
}
|
|
36
|
+
if (schedule.kind === 'at') {
|
|
37
|
+
// One-shot: compute the specific minute/hour/day cron that fires once
|
|
38
|
+
const d = new Date(schedule.at);
|
|
39
|
+
return {
|
|
40
|
+
cron: `${d.getUTCMinutes()} ${d.getUTCHours()} ${d.getUTCDate()} ${d.getUTCMonth() + 1} *`,
|
|
41
|
+
tz: 'UTC',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Unknown schedule kind: ${schedule.kind}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function main() {
|
|
48
|
+
if (!existsSync(JOBS_JSON)) {
|
|
49
|
+
console.error(`No jobs.json found at: ${JOBS_JSON}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let data;
|
|
54
|
+
try {
|
|
55
|
+
data = JSON.parse(readFileSync(JOBS_JSON, 'utf8'));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`Failed to parse ${JOBS_JSON}: ${err.message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const jobs = data.jobs || [];
|
|
61
|
+
|
|
62
|
+
console.log(`Found ${jobs.length} job(s) in ${JOBS_JSON}`);
|
|
63
|
+
|
|
64
|
+
initDb();
|
|
65
|
+
|
|
66
|
+
const existing = listJobs();
|
|
67
|
+
const existingIds = new Set(existing.map(j => j.id));
|
|
68
|
+
|
|
69
|
+
let imported = 0;
|
|
70
|
+
let skipped = 0;
|
|
71
|
+
|
|
72
|
+
for (const job of jobs) {
|
|
73
|
+
if (existingIds.has(job.id)) {
|
|
74
|
+
console.log(` SKIP: ${job.name} (already exists)`);
|
|
75
|
+
skipped++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const { cron, tz } = cronFromSchedule(job.schedule);
|
|
81
|
+
|
|
82
|
+
let deliveryMode = job.delivery?.mode || 'announce';
|
|
83
|
+
const deliveryTo = job.delivery?.to || null;
|
|
84
|
+
let deliveryOptOutReason = null;
|
|
85
|
+
|
|
86
|
+
if ((deliveryMode === 'announce' || deliveryMode === 'announce-always') && !deliveryTo) {
|
|
87
|
+
console.warn(` WARN: ${job.name}: delivery_mode='${deliveryMode}' but no delivery_to configured; downgrading to 'none'`);
|
|
88
|
+
deliveryMode = 'none';
|
|
89
|
+
deliveryOptOutReason = 'migrated: no delivery target configured';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
createJob({
|
|
93
|
+
id: job.id,
|
|
94
|
+
name: job.name,
|
|
95
|
+
enabled: job.enabled !== false,
|
|
96
|
+
schedule_cron: cron,
|
|
97
|
+
schedule_tz: tz,
|
|
98
|
+
session_target: job.sessionTarget || 'isolated',
|
|
99
|
+
agent_id: job.agentId || 'main',
|
|
100
|
+
payload_kind: job.payload?.kind || 'agentTurn',
|
|
101
|
+
payload_message: job.payload?.message || job.payload?.text || '',
|
|
102
|
+
payload_model: job.payload?.model || null,
|
|
103
|
+
payload_thinking: job.payload?.thinking || null,
|
|
104
|
+
payload_timeout_seconds: job.payload?.timeoutSeconds || 120,
|
|
105
|
+
overlap_policy: 'skip',
|
|
106
|
+
run_timeout_ms: 300000,
|
|
107
|
+
delivery_mode: deliveryMode,
|
|
108
|
+
delivery_channel: job.delivery?.channel || null,
|
|
109
|
+
delivery_to: deliveryTo,
|
|
110
|
+
delivery_opt_out_reason: deliveryOptOutReason,
|
|
111
|
+
delete_after_run: job.schedule?.kind === 'at',
|
|
112
|
+
origin: job.origin || 'system',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
console.log(` OK: ${job.name} -> cron="${cron}" tz=${tz}`);
|
|
116
|
+
imported++;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error(` ERR: ${job.name}: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`\nDone: ${imported} imported, ${skipped} skipped`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-scheduler",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"openclaw-scheduler": "bin/openclaw-scheduler.js",
|
|
9
|
+
"openclaw-inbox-consumer": "scripts/inbox-consumer.mjs"
|
|
10
|
+
},
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./index.d.ts",
|
|
15
|
+
"default": "./index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "node dispatcher.js",
|
|
24
|
+
"migrate": "node migrate.js",
|
|
25
|
+
"test": "SCHEDULER_DB=:memory: node test.js",
|
|
26
|
+
"lint": "eslint . --max-warnings=0",
|
|
27
|
+
"coverage": "SCHEDULER_DB=:memory: node scripts/coverage.mjs",
|
|
28
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
29
|
+
"verify:smoke": "node scripts/verify-local.mjs --smoke",
|
|
30
|
+
"verify:local": "node scripts/verify-local.mjs",
|
|
31
|
+
"prepublishOnly": "npm run verify:local"
|
|
32
|
+
},
|
|
33
|
+
"overrides": {
|
|
34
|
+
"brace-expansion": "^5.0.5",
|
|
35
|
+
"flatted": "^3.4.2"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"bin",
|
|
39
|
+
"dispatch/529-recovery.mjs",
|
|
40
|
+
"dispatch/config.example.json",
|
|
41
|
+
"dispatch/deliver-watcher.sh",
|
|
42
|
+
"dispatch/hooks.mjs",
|
|
43
|
+
"dispatch/index.mjs",
|
|
44
|
+
"dispatch/README.md",
|
|
45
|
+
"dispatch/watcher.mjs",
|
|
46
|
+
"scripts/dispatch-cli-utils.mjs",
|
|
47
|
+
"scripts/inbox-consumer.mjs",
|
|
48
|
+
"scripts/stuck-run-detector.mjs",
|
|
49
|
+
"scripts/stuck-detector.sh",
|
|
50
|
+
"scripts/telegram-webhook-check.mjs",
|
|
51
|
+
"agents.js",
|
|
52
|
+
"approval.js",
|
|
53
|
+
"backup.js",
|
|
54
|
+
"cli.js",
|
|
55
|
+
"db.js",
|
|
56
|
+
"dispatch-queue.js",
|
|
57
|
+
"dispatcher-approvals.js",
|
|
58
|
+
"dispatcher-delivery.js",
|
|
59
|
+
"dispatcher-maintenance.js",
|
|
60
|
+
"dispatcher-shell.js",
|
|
61
|
+
"dispatcher-strategies.js",
|
|
62
|
+
"dispatcher-utils.js",
|
|
63
|
+
"dispatcher.js",
|
|
64
|
+
"gateway.js",
|
|
65
|
+
"idempotency.js",
|
|
66
|
+
"index.d.ts",
|
|
67
|
+
"index.js",
|
|
68
|
+
"jobs.js",
|
|
69
|
+
"messages.js",
|
|
70
|
+
"migrate.js",
|
|
71
|
+
"migrate-consolidate.js",
|
|
72
|
+
"paths.js",
|
|
73
|
+
"prompt-context.js",
|
|
74
|
+
"retrieval.js",
|
|
75
|
+
"runs.js",
|
|
76
|
+
"scheduler-schema.js",
|
|
77
|
+
"schema.sql",
|
|
78
|
+
"setup.mjs",
|
|
79
|
+
"shell-result.js",
|
|
80
|
+
"task-tracker.js",
|
|
81
|
+
"team-adapter.js",
|
|
82
|
+
"v02-runtime.js",
|
|
83
|
+
"README.md",
|
|
84
|
+
"LICENSE",
|
|
85
|
+
"CHANGELOG.md",
|
|
86
|
+
"CONTRIBUTING.md",
|
|
87
|
+
"SECURITY.md",
|
|
88
|
+
"CODE_OF_CONDUCT.md",
|
|
89
|
+
"INSTALL*.md",
|
|
90
|
+
"UNINSTALL.md",
|
|
91
|
+
"UPGRADING.md",
|
|
92
|
+
"BEST-PRACTICES.md",
|
|
93
|
+
"QUICK-START.md",
|
|
94
|
+
"AGENTS.md",
|
|
95
|
+
"CONTEXT.md",
|
|
96
|
+
"JOB-QUICK-REF.md",
|
|
97
|
+
"IMPLEMENTATION_SPEC.md",
|
|
98
|
+
"docs"
|
|
99
|
+
],
|
|
100
|
+
"keywords": [
|
|
101
|
+
"openclaw",
|
|
102
|
+
"scheduler",
|
|
103
|
+
"cron",
|
|
104
|
+
"workflow",
|
|
105
|
+
"sqlite",
|
|
106
|
+
"agent",
|
|
107
|
+
"automation"
|
|
108
|
+
],
|
|
109
|
+
"author": "Alex Mittell",
|
|
110
|
+
"license": "MIT",
|
|
111
|
+
"repository": {
|
|
112
|
+
"type": "git",
|
|
113
|
+
"url": "git+https://github.com/amittell/openclaw-scheduler.git"
|
|
114
|
+
},
|
|
115
|
+
"bugs": {
|
|
116
|
+
"url": "https://github.com/amittell/openclaw-scheduler/issues"
|
|
117
|
+
},
|
|
118
|
+
"homepage": "https://github.com/amittell/openclaw-scheduler#readme",
|
|
119
|
+
"dependencies": {
|
|
120
|
+
"better-sqlite3": "^11.10.0",
|
|
121
|
+
"croner": "^10.0.1"
|
|
122
|
+
},
|
|
123
|
+
"devDependencies": {
|
|
124
|
+
"@eslint/js": "^10.0.1",
|
|
125
|
+
"c8": "^11.0.0",
|
|
126
|
+
"eslint": "^10.1.0",
|
|
127
|
+
"globals": "^17.4.0",
|
|
128
|
+
"typescript": "^5.9.2"
|
|
129
|
+
}
|
|
130
|
+
}
|
package/paths.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
function firstNonEmpty(value) {
|
|
9
|
+
if (typeof value !== 'string') return '';
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
return trimmed.length > 0 ? trimmed : '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureWritableDir(dirPath) {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(dirPath, { recursive: true });
|
|
17
|
+
accessSync(dirPath, constants.W_OK);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isNodeModulesInstall(moduleDir) {
|
|
25
|
+
return /[\\/]node_modules[\\/](?:@[^\\/]+[\\/])?openclaw-scheduler(?:[\\/]|$)/.test(moduleDir);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveSchedulerHome(env = process.env) {
|
|
29
|
+
const explicitHome = firstNonEmpty(env.SCHEDULER_HOME);
|
|
30
|
+
if (explicitHome) return explicitHome;
|
|
31
|
+
const home = firstNonEmpty(env.HOME) || homedir();
|
|
32
|
+
return join(home, '.openclaw', 'scheduler');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveSchedulerDbPath(params = {}) {
|
|
36
|
+
const env = params.env || process.env;
|
|
37
|
+
const explicitPath = firstNonEmpty(params.explicitPath);
|
|
38
|
+
if (explicitPath) return explicitPath;
|
|
39
|
+
|
|
40
|
+
const envDbPath = firstNonEmpty(env.SCHEDULER_DB);
|
|
41
|
+
if (envDbPath) return envDbPath;
|
|
42
|
+
|
|
43
|
+
const moduleDir = firstNonEmpty(params.moduleDir) || __dirname;
|
|
44
|
+
const moduleDbPath = join(moduleDir, 'scheduler.db');
|
|
45
|
+
const moduleDirWritable = !isNodeModulesInstall(moduleDir) && ensureWritableDir(moduleDir);
|
|
46
|
+
if (!isNodeModulesInstall(moduleDir) && (existsSync(moduleDbPath) || moduleDirWritable)) {
|
|
47
|
+
return moduleDbPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return join(resolveSchedulerHome(env), 'scheduler.db');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ensureSchedulerDbParent(dbPath) {
|
|
54
|
+
const parent = dirname(dbPath);
|
|
55
|
+
mkdirSync(parent, { recursive: true });
|
|
56
|
+
return parent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveBackupStagingDir(env = process.env) {
|
|
60
|
+
const explicit = firstNonEmpty(env.SCHEDULER_BACKUP_STAGING_DIR);
|
|
61
|
+
if (explicit) return explicit;
|
|
62
|
+
return join(resolveSchedulerHome(env), '.backup-staging');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveArtifactsDir(params = {}) {
|
|
66
|
+
const env = params.env || process.env;
|
|
67
|
+
const explicit = firstNonEmpty(params.explicitPath) || firstNonEmpty(env.SCHEDULER_ARTIFACTS_DIR);
|
|
68
|
+
if (explicit) return explicit;
|
|
69
|
+
const dbPath = firstNonEmpty(params.dbPath);
|
|
70
|
+
if (dbPath && dbPath !== ':memory:') {
|
|
71
|
+
return join(dirname(dbPath), 'artifacts');
|
|
72
|
+
}
|
|
73
|
+
return join(resolveSchedulerHome(env), 'artifacts');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ensureArtifactsDir(dirPath) {
|
|
77
|
+
mkdirSync(dirPath, { recursive: true });
|
|
78
|
+
return dirPath;
|
|
79
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getRun } from './runs.js';
|
|
2
|
+
import { getJob } from './jobs.js';
|
|
3
|
+
import { extractShellResultFromRun } from './shell-result.js';
|
|
4
|
+
|
|
5
|
+
function truncate(text, limit) {
|
|
6
|
+
if (!text || text.length <= limit) return text;
|
|
7
|
+
return `${text.slice(0, Math.max(0, limit - 16))}\n...[truncated]`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildTriggeredRunContext(run, deps = {}) {
|
|
11
|
+
if (!run?.triggered_by_run) {
|
|
12
|
+
return { text: '', meta: {} };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const getRunById = deps.getRunById || getRun;
|
|
16
|
+
const getJobById = deps.getJobById || getJob;
|
|
17
|
+
const parentRun = getRunById(run.triggered_by_run);
|
|
18
|
+
|
|
19
|
+
if (!parentRun) {
|
|
20
|
+
return {
|
|
21
|
+
text: '',
|
|
22
|
+
meta: {
|
|
23
|
+
triggered_by_run: run.triggered_by_run,
|
|
24
|
+
parent_run_missing: true
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parentJob = getJobById(parentRun.job_id);
|
|
30
|
+
const lines = [
|
|
31
|
+
'',
|
|
32
|
+
'--- Trigger Context ---',
|
|
33
|
+
`Triggered by: ${parentJob?.name || parentRun.job_id}`,
|
|
34
|
+
`Parent run status: ${parentRun.status}`
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const shellResult = extractShellResultFromRun(parentRun);
|
|
38
|
+
if (shellResult) {
|
|
39
|
+
lines.push('Parent shell result:');
|
|
40
|
+
if (shellResult.timedOut) {
|
|
41
|
+
lines.push('Timed out: true');
|
|
42
|
+
}
|
|
43
|
+
if (typeof shellResult.exitCode === 'number') {
|
|
44
|
+
lines.push(`Exit code: ${shellResult.exitCode}`);
|
|
45
|
+
}
|
|
46
|
+
if (shellResult.signal) {
|
|
47
|
+
lines.push(`Signal: ${shellResult.signal}`);
|
|
48
|
+
}
|
|
49
|
+
if (shellResult.errorMessage) {
|
|
50
|
+
lines.push(`Error: ${shellResult.errorMessage}`);
|
|
51
|
+
}
|
|
52
|
+
if (shellResult.stdout?.trim()) {
|
|
53
|
+
lines.push('stdout:');
|
|
54
|
+
lines.push(truncate(shellResult.stdout, 3000));
|
|
55
|
+
}
|
|
56
|
+
if (shellResult.stdoutPath) {
|
|
57
|
+
lines.push(`stdout file: ${shellResult.stdoutPath} (${shellResult.stdoutBytes || 0} bytes)`);
|
|
58
|
+
}
|
|
59
|
+
if (shellResult.stderr?.trim()) {
|
|
60
|
+
lines.push('stderr:');
|
|
61
|
+
lines.push(truncate(shellResult.stderr, 3000));
|
|
62
|
+
}
|
|
63
|
+
if (shellResult.stderrPath) {
|
|
64
|
+
lines.push(`stderr file: ${shellResult.stderrPath} (${shellResult.stderrBytes || 0} bytes)`);
|
|
65
|
+
}
|
|
66
|
+
} else if (parentRun.summary?.trim()) {
|
|
67
|
+
lines.push('Parent run output:');
|
|
68
|
+
lines.push(parentRun.summary.slice(0, 5000));
|
|
69
|
+
} else if (parentRun.error_message?.trim()) {
|
|
70
|
+
lines.push('Parent run error:');
|
|
71
|
+
lines.push(parentRun.error_message.slice(0, 2000));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
lines.push('---');
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
text: lines.join('\n'),
|
|
78
|
+
meta: {
|
|
79
|
+
triggered_by_run: parentRun.id,
|
|
80
|
+
parent_job_id: parentRun.job_id,
|
|
81
|
+
parent_job_name: parentJob?.name || null,
|
|
82
|
+
parent_run_status: parentRun.status,
|
|
83
|
+
...(shellResult
|
|
84
|
+
? {
|
|
85
|
+
parent_shell_exit_code: shellResult.exitCode,
|
|
86
|
+
parent_shell_signal: shellResult.signal,
|
|
87
|
+
parent_shell_timed_out: shellResult.timedOut,
|
|
88
|
+
parent_shell_stdout_path: shellResult.stdoutPath || null,
|
|
89
|
+
parent_shell_stderr_path: shellResult.stderrPath || null,
|
|
90
|
+
}
|
|
91
|
+
: {})
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
package/retrieval.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Hybrid retrieval for injecting prior-run context into job prompts
|
|
2
|
+
import { getDb } from './db.js';
|
|
3
|
+
|
|
4
|
+
// ---- TF-IDF helpers ----
|
|
5
|
+
|
|
6
|
+
const STOPWORDS = new Set([
|
|
7
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
8
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'shall',
|
|
9
|
+
'should', 'may', 'might', 'must', 'can', 'could', 'to', 'of', 'in',
|
|
10
|
+
'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through',
|
|
11
|
+
'during', 'before', 'after', 'above', 'below', 'between', 'and',
|
|
12
|
+
'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either', 'neither',
|
|
13
|
+
'each', 'every', 'all', 'any', 'few', 'more', 'most', 'other', 'some',
|
|
14
|
+
'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very', 'just',
|
|
15
|
+
'it', 'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
|
|
16
|
+
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
|
|
17
|
+
'they', 'them', 'their', 'what', 'which', 'who', 'whom',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function tokenize(text) {
|
|
21
|
+
return text
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.map(t => t.replace(/[^a-z0-9]/g, ''))
|
|
25
|
+
.filter(t => t.length > 1 && !STOPWORDS.has(t));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function computeTF(tokens) {
|
|
29
|
+
const freq = {};
|
|
30
|
+
for (const t of tokens) {
|
|
31
|
+
freq[t] = (freq[t] || 0) + 1;
|
|
32
|
+
}
|
|
33
|
+
const len = tokens.length || 1;
|
|
34
|
+
const tf = {};
|
|
35
|
+
for (const [term, count] of Object.entries(freq)) {
|
|
36
|
+
tf[term] = count / len;
|
|
37
|
+
}
|
|
38
|
+
return tf;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function computeIDF(docs) {
|
|
42
|
+
// docs: array of TF maps
|
|
43
|
+
const N = docs.length || 1;
|
|
44
|
+
const df = {}; // document frequency per term
|
|
45
|
+
for (const tf of docs) {
|
|
46
|
+
for (const term of Object.keys(tf)) {
|
|
47
|
+
df[term] = (df[term] || 0) + 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const idf = {};
|
|
51
|
+
for (const [term, count] of Object.entries(df)) {
|
|
52
|
+
idf[term] = Math.log(N / count) + 1; // smoothed IDF
|
|
53
|
+
}
|
|
54
|
+
return idf;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tfidfScore(queryTokens, docTF, idf) {
|
|
58
|
+
let score = 0;
|
|
59
|
+
for (const qt of queryTokens) {
|
|
60
|
+
if (docTF[qt]) {
|
|
61
|
+
score += docTF[qt] * (idf[qt] || 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return score;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract readable text from a context_summary field.
|
|
69
|
+
* The field may be a JSON string (object with string values) or plain text.
|
|
70
|
+
*/
|
|
71
|
+
function extractSummaryText(raw) {
|
|
72
|
+
if (!raw) return '';
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(raw);
|
|
75
|
+
if (parsed && typeof parsed === 'object') {
|
|
76
|
+
return Object.values(parsed)
|
|
77
|
+
.filter(v => typeof v === 'string')
|
|
78
|
+
.join(' ');
|
|
79
|
+
}
|
|
80
|
+
return String(parsed);
|
|
81
|
+
} catch {
|
|
82
|
+
return raw;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- Exports ----
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the last N run summaries (non-null context_summary) for a job.
|
|
90
|
+
*/
|
|
91
|
+
export function getRecentRunSummaries(jobId, limit = 5) {
|
|
92
|
+
return getDb().prepare(`
|
|
93
|
+
SELECT id, job_id, started_at, finished_at, status, context_summary, summary
|
|
94
|
+
FROM runs
|
|
95
|
+
WHERE job_id = ? AND context_summary IS NOT NULL
|
|
96
|
+
ORDER BY started_at DESC
|
|
97
|
+
LIMIT ?
|
|
98
|
+
`).all(jobId, limit);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Search run summaries using hybrid substring + TF-IDF scoring.
|
|
103
|
+
* Returns top N matches from the last 50 runs with summaries.
|
|
104
|
+
*/
|
|
105
|
+
export function searchRunSummaries(jobId, query, limit = 5) {
|
|
106
|
+
// Fetch candidate pool
|
|
107
|
+
const candidates = getDb().prepare(`
|
|
108
|
+
SELECT id, job_id, started_at, finished_at, status, context_summary, summary
|
|
109
|
+
FROM runs
|
|
110
|
+
WHERE job_id = ? AND context_summary IS NOT NULL
|
|
111
|
+
ORDER BY started_at DESC
|
|
112
|
+
LIMIT 50
|
|
113
|
+
`).all(jobId);
|
|
114
|
+
|
|
115
|
+
if (!candidates.length || !query) return candidates.slice(0, limit);
|
|
116
|
+
|
|
117
|
+
const queryTokens = tokenize(query);
|
|
118
|
+
const queryLower = query.toLowerCase();
|
|
119
|
+
|
|
120
|
+
// Build TF for each doc
|
|
121
|
+
const docTFs = candidates.map(c => {
|
|
122
|
+
const text = [extractSummaryText(c.context_summary), c.summary || ''].join(' ');
|
|
123
|
+
return computeTF(tokenize(text));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const idf = computeIDF(docTFs);
|
|
127
|
+
|
|
128
|
+
// Score each candidate
|
|
129
|
+
const scored = candidates.map((c, i) => {
|
|
130
|
+
const text = [extractSummaryText(c.context_summary), c.summary || ''].join(' ').toLowerCase();
|
|
131
|
+
// Substring bonus
|
|
132
|
+
const substringBonus = text.includes(queryLower) ? 1.0 : 0;
|
|
133
|
+
// TF-IDF score
|
|
134
|
+
const tfidf = tfidfScore(queryTokens, docTFs[i], idf);
|
|
135
|
+
return { ...c, _score: tfidf + substringBonus };
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
scored.sort((a, b) => b._score - a._score);
|
|
139
|
+
return scored.slice(0, limit).filter(s => s._score > 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build retrieval context string to inject into a job prompt.
|
|
144
|
+
* Returns empty string if retrieval is disabled or no results found.
|
|
145
|
+
*/
|
|
146
|
+
export function buildRetrievalContext(job) {
|
|
147
|
+
if (!job.context_retrieval || job.context_retrieval === 'none') return '';
|
|
148
|
+
|
|
149
|
+
const limit = job.context_retrieval_limit || 5;
|
|
150
|
+
let runs;
|
|
151
|
+
|
|
152
|
+
if (job.context_retrieval === 'recent') {
|
|
153
|
+
runs = getRecentRunSummaries(job.id, limit);
|
|
154
|
+
} else if (job.context_retrieval === 'hybrid') {
|
|
155
|
+
// Use the job's payload_message as the search query
|
|
156
|
+
runs = searchRunSummaries(job.id, job.payload_message, limit);
|
|
157
|
+
// Fallback to recent if search returns nothing
|
|
158
|
+
if (!runs.length) {
|
|
159
|
+
runs = getRecentRunSummaries(job.id, limit);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!runs.length) return '';
|
|
166
|
+
|
|
167
|
+
const lines = ['--- Prior Run Context ---'];
|
|
168
|
+
for (const run of runs) {
|
|
169
|
+
const date = run.started_at || 'unknown';
|
|
170
|
+
const summaryText = extractSummaryText(run.context_summary) || run.summary || '';
|
|
171
|
+
lines.push(`[${date}] ${summaryText}`);
|
|
172
|
+
}
|
|
173
|
+
lines.push('--- End Prior Run Context ---');
|
|
174
|
+
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|