@goodandready/dsh-cron 0.2.5 → 0.2.7

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,74 @@
1
+ /**
2
+ * External REST surface under /dsh-cron/api/* (#54, ADR-0001).
3
+ *
4
+ * Everything a browser panel needs stays on the local, cross-origin-protected
5
+ * routes; this prefix exists for CI and host-side automation, so it is the only
6
+ * surface guarded by a bearer token. The token lives in the plugin settings as
7
+ * a masked value: an unconfigured token disables the surface (503) instead of
8
+ * leaving it open, and a wrong token is answered with 401.
9
+ *
10
+ * The operations themselves are shared with the panel routes (lib/api.js), so
11
+ * the confirmation gate for code-executing tasks and the config-owned refusals
12
+ * apply here exactly as they do over HTTP from the UI.
13
+ */
14
+
15
+ import { timingSafeEqual } from 'node:crypto';
16
+ import { sendJson } from './http-utils.js';
17
+ import { handleExternalTaskRequest } from './api.js';
18
+
19
+ /** Settings key that holds the bearer token. */
20
+ export const API_TOKEN_SETTING_KEY = 'apiToken';
21
+
22
+ /** Route prefix owned by this surface. */
23
+ export const EXTERNAL_API_PREFIX = '/dsh-cron/api';
24
+
25
+ /**
26
+ * Constant-time token comparison. Length is compared first because
27
+ * timingSafeEqual throws on buffers of different sizes; a length mismatch is
28
+ * not a secret worth hiding for a fixed-length token.
29
+ */
30
+ export function tokenMatches(expected, provided) {
31
+ if (!expected || !provided) return false;
32
+ const a = Buffer.from(String(expected), 'utf8');
33
+ const b = Buffer.from(String(provided), 'utf8');
34
+ if (a.length !== b.length) return false;
35
+ return timingSafeEqual(a, b);
36
+ }
37
+
38
+ /** Bearer token of a request, or an empty string when there is none. */
39
+ export function readBearerToken(req) {
40
+ const header = String((req.headers && req.headers.authorization) || '');
41
+ const match = header.match(/^Bearer\s+(.+)$/i);
42
+ return match ? match[1].trim() : '';
43
+ }
44
+
45
+ /**
46
+ * Guard the external prefix, then hand the request to the shared task
47
+ * operations. `getToken` is read per request so a settings change applies
48
+ * without a restart.
49
+ */
50
+ export function createExternalApiHandler({ store, scheduler, getToken }) {
51
+ return async function handleExternalApi(req, res) {
52
+ const expected = typeof getToken === 'function' ? getToken() : '';
53
+ if (!expected) {
54
+ sendJson(res, 503, {
55
+ ok: false,
56
+ error: 'External API is disabled: set the "' + API_TOKEN_SETTING_KEY + '" setting to enable it',
57
+ });
58
+ return;
59
+ }
60
+ if (!tokenMatches(expected, readBearerToken(req))) {
61
+ sendJson(res, 401, { ok: false, error: 'Unauthorized' });
62
+ return;
63
+ }
64
+ const url = new URL(req.url, 'http://127.0.0.1');
65
+ // `url` must be inside the try: a malformed body (an unparseable schedule,
66
+ // for example) surfaces as a thrown error from the shared handlers, and an
67
+ // unhandled rejection here would answer nothing at all.
68
+ try {
69
+ await handleExternalTaskRequest({ store, scheduler, req, res, url });
70
+ } catch (err) {
71
+ sendJson(res, (err && err.statusCode) || 500, { ok: false, error: (err && err.message) || String(err) });
72
+ }
73
+ };
74
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Failure inspector (#43): after a run fails, ask the model what went wrong and
3
+ * what to change in the task.
4
+ *
5
+ * The value is speed: instead of reading a stack trace or a wall of logs, the
6
+ * history entry carries a short diagnosis and a proposed prompt change the user
7
+ * can apply with one click. Nothing is applied automatically.
8
+ *
9
+ * Fail-open like the silent rule: no flag, no model, a failed call or an
10
+ * unreadable answer leave the run exactly as it was.
11
+ */
12
+
13
+ import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
14
+
15
+ /** Agent-mediated tasks have a prompt worth revising. */
16
+ export const INSPECTABLE_TASK_TYPES = ['llm', 'skill', 'workflow'];
17
+
18
+ export function supportsInspection(task) {
19
+ const type = String((task && task.type) || 'llm');
20
+ return INSPECTABLE_TASK_TYPES.includes(type);
21
+ }
22
+
23
+ const SYSTEM_PROMPT = [
24
+ 'You diagnose a failed scheduled agent task.',
25
+ 'Answer with a single JSON object and nothing else:',
26
+ '{"diagnosis": "what went wrong, in one or two sentences", "suggestion": "a concrete improved prompt or configuration change", "confidence": "low"|"medium"|"high"}',
27
+ 'Be specific and short. If the cause is unclear from the material, say so and set confidence to low.',
28
+ ].join(' ');
29
+
30
+ export function buildInspectPrompt(task, runInfo) {
31
+ const error = String((runInfo && runInfo.error) || '').slice(0, 2000);
32
+ const output = String((runInfo && runInfo.output) || '').slice(0, 2000);
33
+ return [
34
+ `Task: ${task.title || 'scheduled task'} (type: ${task.type || 'llm'})`,
35
+ `Schedule: ${task.scheduleText || task.schedule || ''}`,
36
+ task.model ? `Model: ${task.model}` : '',
37
+ '',
38
+ 'Current prompt:',
39
+ '```',
40
+ String(task.prompt || '').slice(0, 2000),
41
+ '```',
42
+ '',
43
+ `Result: ${(runInfo && runInfo.status) || 'error'}`,
44
+ 'Error:',
45
+ '```',
46
+ error || '(no error text)',
47
+ '```',
48
+ output ? 'Last output:' : '',
49
+ output ? '```' : '',
50
+ output,
51
+ output ? '```' : '',
52
+ ].filter((line) => line !== '').join('\n');
53
+ }
54
+
55
+ /** Read a diagnosis out of a model answer; anything unusable is rejected. */
56
+ export function readDiagnosis(text) {
57
+ const parsed = parseJsonAnswer(text);
58
+ if (!parsed || !parsed.diagnosis) return { ok: false, error: 'the model did not answer with a diagnosis' };
59
+ const confidence = ['low', 'medium', 'high'].includes(String(parsed.confidence || '').toLowerCase())
60
+ ? String(parsed.confidence).toLowerCase()
61
+ : 'low';
62
+ return {
63
+ ok: true,
64
+ diagnosis: String(parsed.diagnosis).slice(0, 1000),
65
+ suggestion: String(parsed.suggestion || '').slice(0, 2000),
66
+ confidence,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Inspect a failed run. Returns `{ inspected: true, diagnosis, suggestion,
72
+ * confidence }` or `{ inspected: false, error }` — never throws.
73
+ */
74
+ export async function inspectFailure({ task, runInfo, ask, preferredModel, timeoutMs }) {
75
+ // Order matters: an inspection only ever concerns failed runs, so a healthy
76
+ // run must not produce a "no model available" warning.
77
+ const status = (runInfo && runInfo.status) || '';
78
+ if (status !== 'error' && status !== 'timeout') return { inspected: false };
79
+ if (!task || !task.inspectOnFailure) return { inspected: false };
80
+ if (!supportsInspection(task)) return { inspected: false, error: 'this task type has no prompt to revise' };
81
+ if (typeof ask !== 'function') return { inspected: false, error: 'no model is available for the inspection' };
82
+
83
+ try {
84
+ const result = await ask({
85
+ task,
86
+ prompt: buildInspectPrompt(task, runInfo),
87
+ system: SYSTEM_PROMPT,
88
+ preferredModel,
89
+ maxTokens: 400,
90
+ timeoutMs,
91
+ });
92
+ if (!result || !result.ok) return { inspected: false, error: (result && result.error) || 'the model call failed' };
93
+ const diagnosis = readDiagnosis(result.text);
94
+ if (!diagnosis.ok) return { inspected: false, error: diagnosis.error };
95
+ return { inspected: true, diagnosis: diagnosis.diagnosis, suggestion: diagnosis.suggestion, confidence: diagnosis.confidence };
96
+ } catch (err) {
97
+ return { inspected: false, error: (err && err.message) || String(err) };
98
+ }
99
+ }
100
+
101
+ /** Ask helper bound to a context, used by the scheduler. */
102
+ export function makeInspectAsk(ctx) {
103
+ return async ({ prompt, system, preferredModel, maxTokens, timeoutMs, task }) => {
104
+ const target = resolveAskTarget(ctx, task || {}, preferredModel);
105
+ return askModel(ctx, { ...target, prompt, system, maxTokens, timeoutMs });
106
+ };
107
+ }
package/lib/http-utils.js CHANGED
@@ -85,3 +85,27 @@ export function pickPatchableFields(body, allowed) {
85
85
  }
86
86
  return patch;
87
87
  }
88
+
89
+ export function sendJson(res, statusCode, data) {
90
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify(data));
92
+ }
93
+
94
+ export function rejectCrossOrigin(req, res) {
95
+ if (isCrossOrigin(req)) {
96
+ sendJson(res, 403, { ok: false, error: 'Cross-origin request rejected' });
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ export async function readBody(req, res) {
103
+ try {
104
+ return { body: await parseJsonBody(req) };
105
+ } catch (err) {
106
+ sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
107
+ return { error: true };
108
+ }
109
+ }
110
+
111
+ /** Settings keys synchronized between the store and the settings service. */