@toddzheng024/dscode-bundle 0.7.25 → 0.7.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/plugins/openrouter/wire.mjs +3 -0
- package/plugins/session-metrics/index.mjs +16 -7
- package/plugins/session-metrics/rate.mjs +26 -61
- package/plugins/session-metrics/view.mjs +26 -12
- package/plugins/triggers/cli.mjs +155 -36
- package/plugins/triggers/commands.mjs +140 -0
- package/plugins/triggers/config.mjs +32 -13
- package/plugins/triggers/host.mjs +28 -21
- package/plugins/triggers/index.mjs +17 -47
- package/plugins/triggers/job-cli.mjs +110 -0
- package/plugins/triggers/jobs.mjs +156 -0
- package/plugins/triggers/lease.mjs +24 -0
- package/plugins/triggers/management.mjs +181 -0
- package/plugins/triggers/options.mjs +8 -0
- package/plugins/triggers/poll.mjs +12 -4
- package/plugins/triggers/run.mjs +1 -0
- package/plugins/triggers/schedule.mjs +40 -0
- package/plugins/triggers/scheduler-service.mjs +46 -0
- package/plugins/triggers/scheduler.mjs +90 -0
- package/plugins/triggers/session.mjs +43 -0
- package/plugins/triggers/source-emit.mjs +19 -0
- package/plugins/triggers/source-host.mjs +78 -0
- package/plugins/triggers/source-ingress.mjs +70 -0
- package/plugins/triggers/source-sandbox.mjs +35 -0
- package/plugins/triggers/sources.mjs +45 -0
- package/plugins/triggers/spool.mjs +23 -6
- package/plugins/triggers/tools.mjs +74 -0
- package/vendor/tui/lib/app.mjs +66 -87
- package/vendor/tui/lib/dscode/preset.mjs +18 -0
- package/vendor/tui/lib/dscode/telemetry.mjs +25 -9
- package/vendor/tui/lib/index.mjs +22 -78
- package/vendor/tui/lib/kernel-panels.mjs +3 -2
- package/vendor/tui/lib/locales/en.mjs +3 -2
- package/vendor/tui/lib/locales/zh.mjs +3 -2
- package/vendor/tui/lib/models.mjs +8 -0
- package/vendor/tui/lib/render/inspector.mjs +1 -1
- package/vendor/tui/lib/render/projection.mjs +11 -6
- package/vendor/tui/lib/render/status.mjs +39 -10
- package/vendor/tui/lib/startup.mjs +4 -5
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Human slash commands share the same durable management service as agent tools.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { formatTrigger } from './config.mjs';
|
|
4
|
+
import { formatJob } from './jobs.mjs';
|
|
5
|
+
import { formatRun, readRuns } from './log.mjs';
|
|
6
|
+
import { formatEvent, listEvents } from './spool.mjs';
|
|
7
|
+
|
|
8
|
+
export const TRIGGER_HELP = `Usage: /trigger (or /triggers)
|
|
9
|
+
list Definitions, registrations and scheduler status
|
|
10
|
+
show <id> Definition and last run
|
|
11
|
+
new <id> Create a disabled, project-local starter
|
|
12
|
+
create|update <id> <JSON> Create or edit definition fields
|
|
13
|
+
enable|disable <id> Enable or pause a trigger
|
|
14
|
+
register|unregister <id> Register or remove its recurring source
|
|
15
|
+
run <id> [text] Queue one event for the scheduler
|
|
16
|
+
schedule <id> <delay|ISO> [text] Queue a delayed event, e.g. 30m
|
|
17
|
+
jobs [id] Jobs and waiting reasons in this workspace
|
|
18
|
+
cancel <jobId> Cancel a pending job
|
|
19
|
+
events <id> Queued events and legacy spool entries
|
|
20
|
+
runs <id> Recent run outcomes
|
|
21
|
+
source <id> [action] status | start | stop | restart | logs
|
|
22
|
+
scheduler [action] status | install
|
|
23
|
+
|
|
24
|
+
A queued event needs a running scheduler. Disabling retains pending jobs;
|
|
25
|
+
stopping a source retains accepted events. Scheduler install starts the shared
|
|
26
|
+
service for all registered projects. Changes require a writable, non-plan session.`;
|
|
27
|
+
|
|
28
|
+
const ok = text => ({ kind: 'success', text });
|
|
29
|
+
const word = input => {
|
|
30
|
+
const match = input.trim().match(/^(\S+)(?:\s+([\s\S]*))?$/u);
|
|
31
|
+
return match ? [match[1], match[2] ?? ''] : ['', ''];
|
|
32
|
+
};
|
|
33
|
+
const usage = text => { throw new Error(`Usage: /trigger ${text}`); };
|
|
34
|
+
const exactId = (input, syntax) => { const [id, rest] = word(input); if (!id || rest) usage(syntax); return id; };
|
|
35
|
+
const schedulerText = state => `Scheduler: ${state.running ? 'running' : 'stopped — pending work waits; use /trigger scheduler install (macOS), or run dscode trigger scheduler start under a service manager'}`;
|
|
36
|
+
|
|
37
|
+
export function triggerCommand({ management, mutationProblem }) {
|
|
38
|
+
return async ({ agent, rawInput = '', commandId, signal }) => {
|
|
39
|
+
try {
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
const [verb, input] = word(rawInput), action = verb || 'list';
|
|
42
|
+
if (['help', '--help', '-h'].includes(action)) return ok(TRIGGER_HELP);
|
|
43
|
+
const project = management.workspace(agent);
|
|
44
|
+
const mutate = () => {
|
|
45
|
+
signal?.throwIfAborted();
|
|
46
|
+
const problem = mutationProblem(agent);
|
|
47
|
+
if (problem) throw new Error(problem);
|
|
48
|
+
};
|
|
49
|
+
if (action === 'list') {
|
|
50
|
+
if (input) usage('list');
|
|
51
|
+
const result = await management.manage({ action: 'list' }, agent);
|
|
52
|
+
const rows = result.definitions.map(definition => {
|
|
53
|
+
const registration = result.sources.find(s => s.triggerId === definition.id) ?? result.schedules.find(s => s.triggerId === definition.id);
|
|
54
|
+
const source = registration?.desired ? `source ${registration.desired} (${registration.status})` : registration ? 'registered' : definition.source.kind === 'external' ? 'external event ingress' : 'not registered';
|
|
55
|
+
return `${formatTrigger(definition, { lastRun: readRuns(management.home, { triggerId: definition.id, limit: 1 })[0] })}\n scheduler: ${source}`;
|
|
56
|
+
});
|
|
57
|
+
return ok([schedulerText(result.scheduler), ...rows, ...(result.problems.length ? ['Unreadable definitions:'] : []), ...result.problems.map(p => `${p.path}: ${p.message}`),
|
|
58
|
+
...(!rows.length ? ['No triggers defined. Use /trigger new <id> to create a disabled starter in .dsh/triggers/.'] : []),
|
|
59
|
+
'Use /trigger help for management commands.'].join('\n'));
|
|
60
|
+
}
|
|
61
|
+
if (['show', 'runs', 'events'].includes(action)) {
|
|
62
|
+
const id = exactId(input, `${action} <id>`);
|
|
63
|
+
const definition = management.definition(project, id);
|
|
64
|
+
if (action === 'show') return ok(formatTrigger(definition, { lastRun: readRuns(management.home, { triggerId: id, limit: 1 })[0] }));
|
|
65
|
+
if (action === 'runs') {
|
|
66
|
+
const runs = readRuns(management.home, { triggerId: id, limit: 0 }).filter(r => !r.cwd || r.cwd === project).slice(0, 20);
|
|
67
|
+
return ok(runs.length ? runs.map(formatRun).join('\n') : `No runs recorded for ${id}.`);
|
|
68
|
+
}
|
|
69
|
+
const pending = listEvents(management.home, id);
|
|
70
|
+
management.withStore(store => {
|
|
71
|
+
for (const job of store.list(id).filter(j => j.project === project && j.workspace === project && j.kind === 'event' && ['pending', 'running'].includes(j.state))) {
|
|
72
|
+
pending.push({ ...JSON.parse(job.payload), eventId: store.eventIdentity(job.id), receivedAt: job.createdAt });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
pending.sort((a, b) => a.receivedAt - b.receivedAt);
|
|
76
|
+
return ok(pending.length ? pending.map(formatEvent).join('\n') : `No pending events for ${id}.`);
|
|
77
|
+
}
|
|
78
|
+
if (action === 'jobs') {
|
|
79
|
+
const id = input ? exactId(input, 'jobs [id]') : undefined;
|
|
80
|
+
const result = await management.jobs({ action: 'list', trigger_id: id, limit: 100 }, agent);
|
|
81
|
+
return ok([result.jobs.length ? result.jobs.map(formatJob).join('\n') : 'No jobs scheduled in this workspace.', schedulerText(result.scheduler)].join('\n'));
|
|
82
|
+
}
|
|
83
|
+
if (action === 'source') {
|
|
84
|
+
const [id, rest] = word(input), operation = rest || 'status';
|
|
85
|
+
if (!id || !['status', 'start', 'stop', 'restart', 'logs'].includes(operation)) usage('source <id> [status|start|stop|restart|logs]');
|
|
86
|
+
if (!['status', 'logs'].includes(operation)) mutate();
|
|
87
|
+
const result = await management.source({ trigger_id: id, action: operation }, agent);
|
|
88
|
+
if (operation === 'logs') return ok(result.log || 'No source output recorded.');
|
|
89
|
+
const s = result.source;
|
|
90
|
+
return ok([`${id}: desired ${s.desired ?? 'unregistered'}; observed ${s.status}${s.pid ? `; pid ${s.pid}` : ''}`,
|
|
91
|
+
...(s.error ? [`Last error: ${s.error}`] : []), ...(s.nextAt ? [`Next attempt: ${new Date(s.nextAt).toISOString()}`] : []),
|
|
92
|
+
schedulerText(result.scheduler), ...(!['status'].includes(operation) ? ['Saved desired state; the scheduler applies it asynchronously. Accepted jobs remain queued.'] : [])].join('\n'));
|
|
93
|
+
}
|
|
94
|
+
if (action === 'scheduler') {
|
|
95
|
+
const operation = input || 'status';
|
|
96
|
+
if (!['status', 'install'].includes(operation)) usage('scheduler [status|install]');
|
|
97
|
+
if (operation === 'install') mutate();
|
|
98
|
+
const result = await management.scheduler({ action: operation });
|
|
99
|
+
return ok([...(result.messages ?? []), schedulerText(result.scheduler ?? result)].join('\n'));
|
|
100
|
+
}
|
|
101
|
+
if (['new', 'create', 'update', 'enable', 'disable', 'register', 'unregister'].includes(action)) {
|
|
102
|
+
let id, definition;
|
|
103
|
+
if (['create', 'update'].includes(action)) {
|
|
104
|
+
let json; [id, json] = word(input);
|
|
105
|
+
if (!id || !json) usage(`${action} <id> <JSON definition fields>`);
|
|
106
|
+
try { definition = JSON.parse(json); } catch { throw new Error('Definition fields must be a JSON object; see /trigger help.'); }
|
|
107
|
+
} else id = exactId(input, `${action} <id>`);
|
|
108
|
+
mutate();
|
|
109
|
+
if (action === 'new') definition = { enabled: false, source: { kind: 'external' }, prompt: 'Describe the task for this trigger.', goal: { objective: 'Describe the outcome to complete.' } };
|
|
110
|
+
const result = await management.manage({ action: action === 'new' ? 'create' : action, trigger_id: id, definition }, agent);
|
|
111
|
+
return ok([`${action}: ${id}`, formatTrigger(result.definition), schedulerText(result.scheduler),
|
|
112
|
+
...(action === 'new' ? ['Starter is disabled. Edit its prompt, goal and source with /trigger update <id> <JSON>, or ask the agent to configure it, then enable it.'] : []),
|
|
113
|
+
...(action === 'disable' ? ['Pending jobs are retained; cancel them separately if needed.'] : []),
|
|
114
|
+
...(action === 'unregister' ? ['Pending recurring jobs were cancelled. Delay and emitted jobs are retained.'] : [])].join('\n'));
|
|
115
|
+
}
|
|
116
|
+
if (action === 'cancel') {
|
|
117
|
+
const id = exactId(input, 'cancel <jobId>'); mutate();
|
|
118
|
+
const result = await management.jobs({ action: 'cancel', job_id: id }, agent);
|
|
119
|
+
return ok(formatJob(result.job));
|
|
120
|
+
}
|
|
121
|
+
if (action === 'schedule' || action === 'run') {
|
|
122
|
+
const [id, rest] = word(input);
|
|
123
|
+
if (!id) usage(action === 'run' ? 'run <id> [text]' : 'schedule <id> <delay|ISO> [text]');
|
|
124
|
+
const identity = `slash-${commandId ?? randomUUID()}`;
|
|
125
|
+
let result;
|
|
126
|
+
if (action === 'schedule') {
|
|
127
|
+
const [when, text] = word(rest);
|
|
128
|
+
if (!when) usage('schedule <id> <delay|ISO> [text]');
|
|
129
|
+
mutate();
|
|
130
|
+
result = await management.jobs({ action: 'schedule', trigger_id: id, ...(/^\d+(?:\.\d+)?[smhd]$/u.test(when) ? { after: when } : { at: when }), event: { source: 'tui', text }, idempotency_key: identity }, agent);
|
|
131
|
+
} else {
|
|
132
|
+
mutate();
|
|
133
|
+
result = await management.emit({ trigger_id: id, eventId: identity, event: { source: 'tui', text: rest } }, agent);
|
|
134
|
+
}
|
|
135
|
+
return ok([formatJob(result.job), schedulerText(result.scheduler), 'Queued durably; this command does not wait for the agent run.'].join('\n'));
|
|
136
|
+
}
|
|
137
|
+
return { kind: 'error', text: `Unknown action "${action}".\n${TRIGGER_HELP}` };
|
|
138
|
+
} catch (error) { return { kind: 'error', text: error.message }; }
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Trigger definitions: the configuration half of the trigger mechanism
|
|
2
2
|
// (docs/triggers-design.md). A definition says what produces an event, which
|
|
3
|
-
// folder the
|
|
3
|
+
// folder the session binds to, what it is asked to do, and the limits that
|
|
4
4
|
// keep an unattended run bounded. Nothing here starts a run: the ingress, the
|
|
5
5
|
// runner and the source installers are separate work, and `/triggers` reads this
|
|
6
6
|
// layer only.
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
13
13
|
import { parse as parseYaml } from 'yaml';
|
|
14
|
+
import { validateCalendar } from './schedule.mjs';
|
|
14
15
|
|
|
15
16
|
/** Source kinds this version accepts; each has its own required fields. */
|
|
16
|
-
export const SOURCE_KINDS = Object.freeze(['interval', 'calendar', 'watch', 'poll', 'external']);
|
|
17
|
+
export const SOURCE_KINDS = Object.freeze(['interval', 'calendar', 'watch', 'poll', 'external', 'script']);
|
|
17
18
|
|
|
18
|
-
/** The
|
|
19
|
+
/** The default fresh-session overlap policy; persistent sessions queue instead. */
|
|
19
20
|
export const OVERLAP = 'skip';
|
|
20
21
|
|
|
21
22
|
/** The only notification policy for now: append to the run log. */
|
|
@@ -70,17 +71,27 @@ function normalizeSource(raw) {
|
|
|
70
71
|
const kind = nonEmptyString(raw.kind, 'source.kind');
|
|
71
72
|
if (!SOURCE_KINDS.includes(kind)) fail(`source.kind must be one of: ${SOURCE_KINDS.join(', ')}`);
|
|
72
73
|
const fields = kind === 'interval' ? ['kind', 'seconds']
|
|
73
|
-
: kind === 'calendar' ? ['kind', 'cron']
|
|
74
|
+
: kind === 'calendar' ? ['kind', 'cron', 'timezone', 'misfire']
|
|
74
75
|
: kind === 'watch' ? ['kind', 'paths']
|
|
75
76
|
: kind === 'poll' ? ['kind', 'everySeconds', 'check']
|
|
76
|
-
: ['kind'];
|
|
77
|
+
: kind === 'script' ? ['kind', 'mode', 'command', 'everySeconds', 'timeoutSeconds', 'permission'] : ['kind'];
|
|
77
78
|
knownKeys(raw, fields, `source (${kind})`);
|
|
79
|
+
if (kind === 'script') {
|
|
80
|
+
if (!['poll', 'daemon'].includes(raw.mode)) fail('source.mode must be poll or daemon');
|
|
81
|
+
if (!Array.isArray(raw.command) || !raw.command.length || raw.command.some(arg => typeof arg !== 'string' || arg.includes('\0')) || !raw.command[0].trim()) fail('source.command must be a non-empty argv array');
|
|
82
|
+
const permission = raw.permission ?? 'read-only';
|
|
83
|
+
if (!['read-only', 'workspace-write'].includes(permission)) fail('source.permission must be read-only or workspace-write');
|
|
84
|
+
if (raw.mode === 'daemon' && (raw.everySeconds !== undefined || raw.timeoutSeconds !== undefined)) fail('daemon sources do not accept everySeconds or timeoutSeconds');
|
|
85
|
+
return { kind, mode: raw.mode, command: raw.command, permission, ...(raw.mode === 'poll' ? { everySeconds: positiveInteger(raw.everySeconds, 'source.everySeconds'), timeoutSeconds: positiveInteger(raw.timeoutSeconds ?? 60, 'source.timeoutSeconds') } : {}) };
|
|
86
|
+
}
|
|
78
87
|
if (kind === 'interval') return { kind, seconds: positiveInteger(raw.seconds, 'source.seconds') };
|
|
79
88
|
if (kind === 'calendar') {
|
|
80
89
|
const cron = nonEmptyString(raw.cron, 'source.cron');
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
const timezone = raw.timezone === undefined ? Intl.DateTimeFormat().resolvedOptions().timeZone : nonEmptyString(raw.timezone, 'source.timezone');
|
|
91
|
+
const misfire = raw.misfire ?? 'run-once';
|
|
92
|
+
if (!['run-once', 'skip'].includes(misfire)) fail('source.misfire must be run-once or skip');
|
|
93
|
+
validateCalendar(cron, timezone);
|
|
94
|
+
return { kind, cron, timezone, misfire };
|
|
84
95
|
}
|
|
85
96
|
if (kind === 'watch') {
|
|
86
97
|
if (!Array.isArray(raw.paths) || raw.paths.length === 0) fail('source.paths must be a non-empty list of paths');
|
|
@@ -101,7 +112,7 @@ function normalizeSource(raw) {
|
|
|
101
112
|
*/
|
|
102
113
|
export function normalizeTrigger(raw, { origin, path } = {}) {
|
|
103
114
|
if (!isPlainObject(raw)) fail('a trigger definition must be a mapping');
|
|
104
|
-
knownKeys(raw, ['id', 'enabled', 'source', 'workspace', 'prompt', 'preset', 'permission', 'model', 'effort', 'goal', 'limits', 'overlap', 'notify'], 'the definition');
|
|
115
|
+
knownKeys(raw, ['id', 'enabled', 'source', 'workspace', 'prompt', 'preset', 'permission', 'model', 'effort', 'session', 'goal', 'limits', 'overlap', 'notify'], 'the definition');
|
|
105
116
|
const id = nonEmptyString(raw.id, 'id');
|
|
106
117
|
if (!ID_PATTERN.test(id)) fail('id must start with a letter or digit and use only a-z, 0-9, dot, underscore or dash');
|
|
107
118
|
const workspace = nonEmptyString(raw.workspace, 'workspace');
|
|
@@ -115,6 +126,11 @@ export function normalizeTrigger(raw, { origin, path } = {}) {
|
|
|
115
126
|
fail(`permission must be one of: ${UNATTENDED_PERMISSIONS.join(', ')} — an unattended run cannot ask for approval`);
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
const session = raw.session === undefined ? { mode: 'new' } : raw.session;
|
|
130
|
+
if (!isPlainObject(session)) fail('session must be an object with mode: new or persistent');
|
|
131
|
+
knownKeys(session, ['mode'], 'session');
|
|
132
|
+
if (!['new', 'persistent'].includes(session.mode)) fail('session.mode must be new or persistent');
|
|
133
|
+
|
|
118
134
|
const goal = raw.goal;
|
|
119
135
|
if (!isPlainObject(goal)) fail('goal must be an object with the objective to finish');
|
|
120
136
|
knownKeys(goal, ['objective', 'maxRounds'], 'goal');
|
|
@@ -129,7 +145,8 @@ export function normalizeTrigger(raw, { origin, path } = {}) {
|
|
|
129
145
|
const minIntervalSeconds = limits.minIntervalSeconds === undefined ? DEFAULTS.minIntervalSeconds : positiveInteger(limits.minIntervalSeconds, 'limits.minIntervalSeconds');
|
|
130
146
|
const maxCostUsd = limits.maxCostUsd === undefined ? undefined : positiveNumber(limits.maxCostUsd, 'limits.maxCostUsd');
|
|
131
147
|
|
|
132
|
-
|
|
148
|
+
const overlap = session.mode === 'persistent' ? 'queue' : OVERLAP;
|
|
149
|
+
if (raw.overlap !== undefined && raw.overlap !== overlap) fail(`overlap must be "${overlap}" for session.mode ${session.mode}`);
|
|
133
150
|
if (raw.notify !== undefined && raw.notify !== NOTIFY) fail(`notify must be "${NOTIFY}"`);
|
|
134
151
|
|
|
135
152
|
const source = normalizeSource(raw.source);
|
|
@@ -141,7 +158,8 @@ export function normalizeTrigger(raw, { origin, path } = {}) {
|
|
|
141
158
|
...(raw.effort === undefined ? {} : { effort: nonEmptyString(raw.effort, 'effort') }),
|
|
142
159
|
goal: { objective, maxRounds },
|
|
143
160
|
limits: { timeoutSeconds, maxRunsPerDay, minIntervalSeconds, ...(maxCostUsd === undefined ? {} : { maxCostUsd }) },
|
|
144
|
-
|
|
161
|
+
session: { mode: session.mode },
|
|
162
|
+
overlap,
|
|
145
163
|
notify: NOTIFY,
|
|
146
164
|
origin: origin ?? 'user',
|
|
147
165
|
path: path ?? '',
|
|
@@ -227,14 +245,15 @@ const inline = (text, limit = 96) => {
|
|
|
227
245
|
*/
|
|
228
246
|
export function formatTrigger(definition, { lastRun } = {}) {
|
|
229
247
|
const source = definition.source.kind === 'interval' ? `every ${definition.source.seconds}s`
|
|
230
|
-
: definition.source.kind === 'calendar' ? `cron ${definition.source.cron}`
|
|
248
|
+
: definition.source.kind === 'calendar' ? `cron ${definition.source.cron} (${definition.source.timezone}, ${definition.source.misfire})`
|
|
231
249
|
: definition.source.kind === 'watch' ? `watch ${definition.source.paths.join(', ')}`
|
|
232
250
|
: definition.source.kind === 'poll' ? `poll ${definition.source.everySeconds}s: ${definition.source.check}`
|
|
233
|
-
: 'external (emit only)';
|
|
251
|
+
: definition.source.kind === 'script' ? `script ${definition.source.mode}: ${definition.source.command.join(' ')}` : 'external (emit only)';
|
|
234
252
|
return [
|
|
235
253
|
`${definition.enabled ? 'on ' : 'off'} ${definition.id}${definition.overrides ? ' (project overrides user)' : ''}`,
|
|
236
254
|
` source: ${source}`,
|
|
237
255
|
` workspace: ${definition.workspace}`,
|
|
256
|
+
` session: ${definition.session?.mode ?? 'new'}`,
|
|
238
257
|
` goal: ${inline(definition.goal.objective)} (max ${definition.goal.maxRounds} rounds)`,
|
|
239
258
|
` prompt: ${inline(definition.prompt)}`,
|
|
240
259
|
` limits: ${definition.limits.timeoutSeconds}s, ${definition.limits.maxRunsPerDay}/day${definition.limits.maxCostUsd === undefined ? '' : `, $${definition.limits.maxCostUsd}`}`,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Host-side half of one trigger run: the
|
|
1
|
+
// Host-side half of one trigger run: the fresh or resumed session an event uses. Unlike
|
|
2
2
|
// `dscode exec`, which runs exactly one turn, this one keeps going while the
|
|
3
3
|
// goal is active — the round driver supplies the continuation — and stops on the
|
|
4
4
|
// goal's own end, a cap, the timeout, or a needed approval.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// It owns the agent, the goal and the transcript; the parent process owns the
|
|
7
7
|
// lock, the limits and the run record. The two swap files: `DSCODE_TRIGGER_OPTIONS`
|
|
8
8
|
// in, `<options>.result.json` out.
|
|
9
|
-
import {
|
|
9
|
+
import { openTriggerSession } from './session.mjs';
|
|
10
10
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
11
11
|
import { decideStop } from './run.mjs';
|
|
12
12
|
import { readRunSpec, writeRunResult } from './options.mjs';
|
|
@@ -17,10 +17,10 @@ import { triggerOverlay } from './overlay.mjs';
|
|
|
17
17
|
export { triggerOverlay };
|
|
18
18
|
|
|
19
19
|
export const name = 'dscode-trigger-host';
|
|
20
|
-
export const inject = ['agents', 'agentPresets', 'agentDefaultModel', 'permissionPresets', 'llm', 'goals', 'appExit'];
|
|
20
|
+
export const inject = ['agents', 'sessions', 'agentPresets', 'agentDefaultModel', 'permissionPresets', 'llm', 'goals', 'appExit'];
|
|
21
21
|
|
|
22
22
|
export function apply(ctx) {
|
|
23
|
-
void
|
|
23
|
+
void runTriggerHost(ctx).catch(error => { process.stderr.write(`dscode trigger run: ${error.message}\n`); ctx.get('appExit')(1); });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function splitRoute(value) {
|
|
@@ -29,9 +29,8 @@ function splitRoute(value) {
|
|
|
29
29
|
return [value.slice(0, at), value.slice(at + 1)];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
async function
|
|
32
|
+
export async function runTriggerHost(ctx, { optionsPath = process.env.DSCODE_TRIGGER_OPTIONS, home = process.env.DSH_HOME, spend = sessionSpend } = {}) {
|
|
33
33
|
await ctx.get('loader').await();
|
|
34
|
-
const optionsPath = process.env.DSCODE_TRIGGER_OPTIONS;
|
|
35
34
|
if (!optionsPath) throw new Error('DSCODE_TRIGGER_OPTIONS is missing');
|
|
36
35
|
const spec = readRunSpec(optionsPath);
|
|
37
36
|
const resultPath = `${optionsPath}.result.json`;
|
|
@@ -42,12 +41,7 @@ async function run(ctx) {
|
|
|
42
41
|
const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) };
|
|
43
42
|
const setup = async agentCtx => { await ctx.agentPresets.mount(agentCtx, spec.preset ?? 'dscode'); };
|
|
44
43
|
|
|
45
|
-
const handle = await ctx
|
|
46
|
-
sessionId: randomUUID(),
|
|
47
|
-
meta: { cwd: spec.workspace, agentPreset: spec.preset ?? 'dscode' },
|
|
48
|
-
agentOptions,
|
|
49
|
-
setup,
|
|
50
|
-
});
|
|
44
|
+
const handle = await openTriggerSession(ctx, spec, { home, agentOptions, setup });
|
|
51
45
|
const agent = handle.agent;
|
|
52
46
|
const session = agent.session;
|
|
53
47
|
if (spec.permission) {
|
|
@@ -58,6 +52,17 @@ async function run(ctx) {
|
|
|
58
52
|
// The goal is created through the SERVICE, not the `create_goal` tool: the tool
|
|
59
53
|
// requires a direct human turn, and an unattended run has none. The service is
|
|
60
54
|
// the same path the human-facing /goal command uses.
|
|
55
|
+
// Every event gets a new goal, including after a blocked/timed-out run. Clear
|
|
56
|
+
// through the service so the previous goal remains in the durable history.
|
|
57
|
+
const previousGoal = ctx.goals.get(agent);
|
|
58
|
+
if (previousGoal) ctx.goals.clear(agent, { id: previousGoal.id, revision: previousGoal.revision });
|
|
59
|
+
const initialCost = spend(session.id).cost;
|
|
60
|
+
const runCost = () => {
|
|
61
|
+
try {
|
|
62
|
+
const total = spend(session.id).cost;
|
|
63
|
+
return Number.isFinite(initialCost) && Number.isFinite(total) ? Math.max(0, total - initialCost) : undefined;
|
|
64
|
+
} catch { return undefined; }
|
|
65
|
+
};
|
|
61
66
|
ctx.goals.create(agent, { objective: spec.goal.objective, maxGoalRounds: spec.goal.maxRounds });
|
|
62
67
|
|
|
63
68
|
let finished = false;
|
|
@@ -65,16 +70,19 @@ async function run(ctx) {
|
|
|
65
70
|
let lastText = '';
|
|
66
71
|
const limiter = spec.limits ?? {};
|
|
67
72
|
|
|
68
|
-
const finish = (result, tail) => {
|
|
73
|
+
const finish = async (result, tail) => {
|
|
69
74
|
if (finished) return;
|
|
70
75
|
finished = true;
|
|
71
76
|
if (tail !== undefined && tail.trim() !== '') {
|
|
72
|
-
try { writeRunTail(
|
|
77
|
+
try { writeRunTail(home ?? '.', spec.triggerId, spec.runId, tail); } catch { /* the record still explains the run */ }
|
|
73
78
|
}
|
|
74
79
|
try {
|
|
80
|
+
await ctx.sessions.flush(session);
|
|
75
81
|
writeRunResult(resultPath, { ...result, sessionId: session.id });
|
|
76
82
|
} catch (error) {
|
|
77
|
-
process.stderr.write(`dscode trigger run: could not
|
|
83
|
+
process.stderr.write(`dscode trigger run: could not persist the run result: ${error.message}\n`);
|
|
84
|
+
ctx.get('appExit')(1);
|
|
85
|
+
return;
|
|
78
86
|
}
|
|
79
87
|
ctx.get('appExit')(result.exitCode);
|
|
80
88
|
};
|
|
@@ -84,11 +92,10 @@ async function run(ctx) {
|
|
|
84
92
|
if (finished) return;
|
|
85
93
|
let goal;
|
|
86
94
|
try { goal = ctx.goals.get(agent); } catch { goal = undefined; }
|
|
87
|
-
|
|
88
|
-
try { costUsd = sessionSpend(session.id).cost; } catch { costUsd = undefined; }
|
|
95
|
+
const costUsd = runCost();
|
|
89
96
|
const decision = decideStop({ goal, costUsd, limits: limiter, approvalsRejected });
|
|
90
97
|
if (!decision.stop) return;
|
|
91
|
-
finish({
|
|
98
|
+
void finish({
|
|
92
99
|
outcome: decision.outcome,
|
|
93
100
|
reason: decision.reason ?? null,
|
|
94
101
|
exitCode: decision.exitCode,
|
|
@@ -110,7 +117,7 @@ async function run(ctx) {
|
|
|
110
117
|
});
|
|
111
118
|
ctx.on('session/disposed', source => {
|
|
112
119
|
if (source.id !== session.id) return;
|
|
113
|
-
finish({ outcome: 'failed', reason: 'model_error', exitCode: 1, cost: null, rounds: null }, lastText);
|
|
120
|
+
void finish({ outcome: 'failed', reason: 'model_error', exitCode: 1, cost: null, rounds: null }, lastText);
|
|
114
121
|
});
|
|
115
122
|
// No human is present: an approval request is refused, and the run is marked
|
|
116
123
|
// as having needed one so the operator sees it in the record.
|
|
@@ -124,11 +131,11 @@ async function run(ctx) {
|
|
|
124
131
|
if (Number.isFinite(limiter.timeoutSeconds) && limiter.timeoutSeconds > 0) {
|
|
125
132
|
setTimeout(() => {
|
|
126
133
|
const goal = (() => { try { return ctx.goals.get(agent); } catch { return undefined; } })();
|
|
127
|
-
finish({
|
|
134
|
+
void finish({
|
|
128
135
|
outcome: 'timedout',
|
|
129
136
|
reason: approvalsRejected ? 'approval_required' : 'timeout',
|
|
130
137
|
exitCode: 124,
|
|
131
|
-
cost: (
|
|
138
|
+
cost: runCost() ?? null,
|
|
132
139
|
rounds: Number.isFinite(goal?.roundsStarted) ? goal.roundsStarted : null,
|
|
133
140
|
}, lastText);
|
|
134
141
|
}, limiter.timeoutSeconds * 1000).unref();
|
|
@@ -1,57 +1,27 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { loadTriggerDefinitions, formatTrigger } from './config.mjs';
|
|
6
|
-
import { formatRun, readRuns } from './log.mjs';
|
|
7
|
-
import { formatEvent, listEvents } from './spool.mjs';
|
|
1
|
+
// TUI management and agent tools use one definitions/jobs/source service.
|
|
2
|
+
import { registerTriggerTools, mutationProblem } from './tools.mjs';
|
|
3
|
+
import { TriggerManagement } from './management.mjs';
|
|
4
|
+
import { triggerCommand } from './commands.mjs';
|
|
8
5
|
import { homedir } from 'node:os';
|
|
9
6
|
import { join } from 'node:path';
|
|
10
7
|
|
|
11
8
|
export const name = 'dscode-triggers';
|
|
12
9
|
export const inject = ['commands'];
|
|
13
|
-
|
|
14
|
-
/** The state directory the launcher hands the child, as the other plugins read it. */
|
|
15
10
|
const stateHome = () => process.env.DSH_HOME ?? process.env.DSCODE_HOME ?? join(homedir(), '.local/share/dscode-hub');
|
|
16
11
|
|
|
17
12
|
export function apply(ctx) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
? { kind: 'success', text: `No pending events for ${id}.` }
|
|
32
|
-
: { kind: 'success', text: pending.map(formatEvent).join('\n') };
|
|
33
|
-
}
|
|
34
|
-
if (action === 'runs') {
|
|
35
|
-
if (id === undefined || id === '' || extra !== undefined) return { kind: 'error', text: 'Usage: /triggers runs <id>' };
|
|
36
|
-
const runs = readRuns(stateHome(), { triggerId: id, limit: 20 });
|
|
37
|
-
return runs.length === 0
|
|
38
|
-
? { kind: 'success', text: `No runs recorded for ${id}.` }
|
|
39
|
-
: { kind: 'success', text: runs.map(formatRun).join('\n') };
|
|
40
|
-
}
|
|
41
|
-
if (action !== undefined && action !== '' && action !== 'list' && action !== 'show') {
|
|
42
|
-
return { kind: 'error', text: `Unknown action "${action}". Usage: /triggers [list|show <id>|events <id>|runs <id>]` };
|
|
43
|
-
}
|
|
44
|
-
if (action === 'show') {
|
|
45
|
-
if (id === undefined || id === '') return { kind: 'error', text: `Usage: /triggers show <id>\nKnown ids: ${definitions.map(definition => definition.id).join(', ') || '(none)'}` };
|
|
46
|
-
const found = definitions.find(definition => definition.id === id);
|
|
47
|
-
if (found === undefined) return { kind: 'error', text: `No trigger "${id}" in ${stateHome()}/triggers or ${workspace ?? '(no workspace)'}/.dsh/triggers` };
|
|
48
|
-
if (extra !== undefined) return { kind: 'error', text: `Usage: /triggers show <id>` };
|
|
49
|
-
return { kind: 'success', text: formatTrigger(found, { lastRun: lastRun(found.id) }) };
|
|
50
|
-
}
|
|
51
|
-
if (definitions.length === 0 && problems.length === 0) {
|
|
52
|
-
return { kind: 'success', text: `No triggers defined.\nAdd one as ${stateHome()}/triggers/<id>.yml, or per project as <workspace>/.dsh/triggers/<id>.yml.\nThe definition format is in docs/triggers-design.md; running them is not implemented yet.` };
|
|
53
|
-
}
|
|
54
|
-
return { kind: 'success', text: [...definitions.map(definition => formatTrigger(definition, { lastRun: lastRun(definition.id) })), ...problemsText].join('\n') };
|
|
55
|
-
},
|
|
13
|
+
const home = stateHome();
|
|
14
|
+
let managementContext;
|
|
15
|
+
ctx.inject(['tools', 'systemPrompt', 'permissionPresets'], toolCtx => {
|
|
16
|
+
managementContext = toolCtx;
|
|
17
|
+
registerTriggerTools(toolCtx, { home });
|
|
18
|
+
});
|
|
19
|
+
const management = new TriggerManagement({ home, dscodePath: process.env.DSCODE_CLI_PATH });
|
|
20
|
+
const handler = triggerCommand({ management, mutationProblem: agent => managementContext
|
|
21
|
+
? mutationProblem(managementContext, agent) : 'Trigger management services are not available yet.' });
|
|
22
|
+
for (const name of ['trigger', 'triggers']) ctx.commands.register({
|
|
23
|
+
name, description: 'Manage triggers, jobs and script sources; /trigger help',
|
|
24
|
+
input: { hint: '[list|show|new|update|enable|disable|run|schedule|jobs|cancel|source|scheduler|help]' },
|
|
25
|
+
handler,
|
|
56
26
|
});
|
|
57
27
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { JobStore, formatJob } from './jobs.mjs';
|
|
3
|
+
import { normalizeEvent } from './spool.mjs';
|
|
4
|
+
import { dueTime } from './schedule.mjs';
|
|
5
|
+
import { runScheduler } from './scheduler.mjs';
|
|
6
|
+
import { schedulerService } from './scheduler-service.mjs';
|
|
7
|
+
import { acquireTriggerLease } from './lease.mjs';
|
|
8
|
+
import { appendRun, readRuns } from './log.mjs';
|
|
9
|
+
|
|
10
|
+
export const JOB_COMMANDS = ['schedule', 'jobs', 'cancel', 'scheduler', 'run-job'];
|
|
11
|
+
|
|
12
|
+
export async function handleJobCommand(options, context) {
|
|
13
|
+
const { home, project, now, platform, dscodePath, launchctl, out, err, deps, findDefinition, readEventBody } = context;
|
|
14
|
+
if (options.command === 'scheduler') {
|
|
15
|
+
if (!['start', 'tick', 'install', 'uninstall', 'status'].includes(options.id)) throw new Error('scheduler expects start, tick, install, uninstall or status');
|
|
16
|
+
if (options.id === 'status') {
|
|
17
|
+
const lease = await acquireTriggerLease(home, '_scheduler');
|
|
18
|
+
out(lease ? 'scheduler stopped' : 'scheduler running');
|
|
19
|
+
lease?.release();
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (!dscodePath || !existsSync(dscodePath)) throw new Error('the scheduler needs a valid dscode program path');
|
|
23
|
+
if (['install', 'uninstall'].includes(options.id)) {
|
|
24
|
+
await schedulerService(options.id, { home, dscodePath, platform, launchctl, agentsDirectory: deps.agentsDirectory, out });
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const stop = () => controller.abort();
|
|
29
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, stop);
|
|
30
|
+
try {
|
|
31
|
+
out(`scheduler ${options.id}: ${home}`);
|
|
32
|
+
return await runScheduler({ home, dscodePath, once: options.id === 'tick', signal: controller.signal, spawnWorker: deps.spawnWorker, report: err });
|
|
33
|
+
} finally { for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, stop); }
|
|
34
|
+
}
|
|
35
|
+
const store = new JobStore(home);
|
|
36
|
+
try {
|
|
37
|
+
if (options.command === 'jobs') {
|
|
38
|
+
const jobs = store.list(options.id || undefined);
|
|
39
|
+
out(jobs.length ? jobs.map(formatJob).join('\n') : 'No jobs scheduled.');
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (options.command === 'cancel') {
|
|
43
|
+
store.cancel(options.id, now);
|
|
44
|
+
out(`cancelled ${options.id}`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
if (options.command === 'schedule') {
|
|
48
|
+
const definition = findDefinition(home, project, options.id);
|
|
49
|
+
const dueAt = dueTime(options, now);
|
|
50
|
+
const payload = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
51
|
+
normalizeEvent(definition.id, payload, { eventId: 'validation', now });
|
|
52
|
+
if (options.eventId !== undefined) throw new Error('schedule assigns a jobId; --event-id is only for emit/fire/run');
|
|
53
|
+
const job = store.create({ triggerId: definition.id, project, workspace: definition.workspace, payload, dueAt, now });
|
|
54
|
+
out(formatJob(job));
|
|
55
|
+
out('The scheduler delivers this job: dscode trigger scheduler install (or scheduler start).');
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
return await executeJob(store, options.id, context);
|
|
59
|
+
} finally { store.close(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function executeJob(store, id, context) {
|
|
63
|
+
const { home, now, deps, out, err, executeEvent, findDefinition } = context;
|
|
64
|
+
const job = store.get(id);
|
|
65
|
+
if (!job) throw new Error(`no job "${id}"`);
|
|
66
|
+
if (job.state !== 'pending' || job.availableAt > now) { out(formatJob(job)); return 0; }
|
|
67
|
+
const triggerLease = await acquireTriggerLease(home, job.triggerId);
|
|
68
|
+
if (!triggerLease) { store.defer(id, 'already_running', now + 1000); return 0; }
|
|
69
|
+
let handle;
|
|
70
|
+
try {
|
|
71
|
+
let definition;
|
|
72
|
+
try { definition = findDefinition(home, job.project, job.triggerId); }
|
|
73
|
+
catch (error) { store.defer(id, 'definition_missing', now + 30000); err(error.message); return 0; }
|
|
74
|
+
if (definition.workspace !== job.workspace) {
|
|
75
|
+
store.defer(id, 'workspace_changed', now + 30000);
|
|
76
|
+
err(`job ${id} belongs to ${job.workspace}; cancel it and schedule a new job for the changed workspace`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
const eventId = store.eventIdentity(job.id) ?? `job:${job.id}`;
|
|
80
|
+
const chosen = normalizeEvent(job.triggerId, { source: job.kind, ...JSON.parse(job.payload) }, { eventId, now: job.createdAt });
|
|
81
|
+
const result = await executeEvent(definition, {
|
|
82
|
+
home, now, deps, out, err, triggerLease, chosen, eventId, jobId: id, quietSkips: true,
|
|
83
|
+
beforeRun: planned => { handle = planned; return store.claim(id, planned.runId, now); },
|
|
84
|
+
});
|
|
85
|
+
if (result.skip) {
|
|
86
|
+
let availableAt = now + 30000;
|
|
87
|
+
const runs = readRuns(home, { triggerId: job.triggerId, limit: 0 }).filter(r => r.outcome !== 'skipped');
|
|
88
|
+
if (result.skip === 'too_soon' && runs[0]) availableAt = runs[0].startedAt + definition.limits.minIntervalSeconds * 1000;
|
|
89
|
+
if (result.skip === 'over_daily_limit') {
|
|
90
|
+
const withinDay = runs.filter(r => r.startedAt > now - 86400000).sort((a, b) => a.startedAt - b.startedAt);
|
|
91
|
+
if (withinDay[0]) availableAt = withinDay[0].startedAt + 86400000;
|
|
92
|
+
}
|
|
93
|
+
if (result.skip === 'duplicate') {
|
|
94
|
+
// Reconcile a durable run record after a crash before job-state update.
|
|
95
|
+
const previous = runs.find(r => r.eventId === eventId);
|
|
96
|
+
if (store.claim(id, previous.runId, now)) store.finish(id, previous, now);
|
|
97
|
+
} else store.defer(id, result.skip, availableAt);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
store.finish(id, result.record ?? { exitCode: result.code, reason: 'no_match' });
|
|
101
|
+
return result.code;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (handle && store.get(id).state === 'running') {
|
|
104
|
+
const record = { ...handle, jobId: id, outcome: 'failed', reason: 'model_error', exitCode: 1, endedAt: Date.now(), cwd: job.workspace };
|
|
105
|
+
appendRun(home, record);
|
|
106
|
+
store.finish(id, record);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
} finally { triggerLease.release(); }
|
|
110
|
+
}
|