@toddzheng024/dscode-bundle 0.7.25 → 0.7.27
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
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.7.
|
|
2
|
+
"version": "0.7.27",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Todd Zheng",
|
|
@@ -308,6 +308,7 @@
|
|
|
308
308
|
"@deepseek-ai/schemastery": "3.18.2",
|
|
309
309
|
"@anionex/dsh-computer-use": "0.3.2",
|
|
310
310
|
"chrome-devtools-mcp": "1.9.0",
|
|
311
|
+
"cron-parser": "5.7.0",
|
|
311
312
|
"imapflow": "2.0.5",
|
|
312
313
|
"mailparser": "3.9.28",
|
|
313
314
|
"nodemailer": "10.0.10",
|
|
@@ -219,6 +219,9 @@ export function errorCode(status, error) {
|
|
|
219
219
|
const code = Number.isInteger(status) ? status : Number(error?.code);
|
|
220
220
|
// A 402 names token counts ("fewer max_tokens"); its status decides before any wording does.
|
|
221
221
|
if (code === 402) return QUOTA_EXCEEDED_CODE;
|
|
222
|
+
// OpenRouter can reject a replay before generation without typed metadata.
|
|
223
|
+
// Keep this narrow: an ordinary permission-denied 403 still means AUTH.
|
|
224
|
+
if (code === 403 && typeof error?.message === 'string' && /^Request blocked by content filter\b/i.test(error.message)) return 'CONTENT_POLICY';
|
|
222
225
|
const detail = [error?.message, error?.metadata?.raw].filter(value => typeof value === 'string').join(' ');
|
|
223
226
|
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE;
|
|
224
227
|
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE;
|
|
@@ -6,7 +6,7 @@ import { BALANCE_PROVIDERS, refreshBalance } from './balance.mjs';
|
|
|
6
6
|
import { refreshOpenRouterModels } from '../openrouter/models.mjs';
|
|
7
7
|
import { REPLAY_KIND } from '../openrouter/wire.mjs';
|
|
8
8
|
import { providerSpec } from '../providers/catalog.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { createSmoothedRate } from './rate.mjs';
|
|
10
10
|
import { currentCharge } from './attribution.mjs';
|
|
11
11
|
|
|
12
12
|
// Chunks that carry generated output; the first one marks time to first token.
|
|
@@ -15,18 +15,20 @@ export const name = 'dscode-session-metrics';
|
|
|
15
15
|
export const inject = ['llm', 'agents', 'tokenMeter', 'sessionProjections'];
|
|
16
16
|
export function apply(ctx) {
|
|
17
17
|
const snapshots = new WeakMap();
|
|
18
|
-
const
|
|
18
|
+
const smoothedRate = createSmoothedRate();
|
|
19
|
+
const activeRequests = new WeakMap();
|
|
19
20
|
ctx.effect(() => setMetricSource(id => {
|
|
20
21
|
const agent = ctx.agents.get(id);
|
|
21
22
|
if (!agent) return undefined;
|
|
22
23
|
const session = agent.session;
|
|
23
24
|
const cached = snapshots.get(session);
|
|
24
|
-
|
|
25
|
+
const requestActive = (activeRequests.get(session) ?? 0) > 0;
|
|
26
|
+
if (cached?.seq === session.seq) return { ...cached.value, currentTps: smoothedRate.get(session), requestActive };
|
|
25
27
|
const state = ctx.sessionProjections.stateOf(session, 'contextPressure');
|
|
26
28
|
const measurement = ctx.tokenMeter.measure(session);
|
|
27
29
|
const value = { events: session.snapshotEvents(), used: measurement.totalTokens, capacity: state?.contextWindow };
|
|
28
30
|
snapshots.set(session, { seq: session.seq, value });
|
|
29
|
-
return { ...value, currentTps:
|
|
31
|
+
return { ...value, currentTps: smoothedRate.get(session), requestActive };
|
|
30
32
|
}));
|
|
31
33
|
const home = process.env.DSH_HOME;
|
|
32
34
|
// Remaining balance is best-effort decoration: resolve the key lazily (never
|
|
@@ -72,19 +74,26 @@ export function apply(ctx) {
|
|
|
72
74
|
save({ kind: 'start', id, time, provider: options.provider, model: options.model, purpose });
|
|
73
75
|
let usage, firstTokenTime, billed;
|
|
74
76
|
const liveSession = purpose === 'agent' ? ctx.agents.get(sessionId)?.session : undefined;
|
|
77
|
+
const settleRate = liveSession
|
|
78
|
+
? smoothedRate.begin(liveSession, JSON.stringify([options.provider, options.model, options.reasoningEffort ?? null]))
|
|
79
|
+
: undefined;
|
|
80
|
+
if (liveSession) activeRequests.set(liveSession, (activeRequests.get(liveSession) ?? 0) + 1);
|
|
81
|
+
let completed = false;
|
|
75
82
|
try {
|
|
76
83
|
for await (const chunk of next()) {
|
|
77
84
|
if (chunk.type === 'usage') usage = chunk.usage;
|
|
78
85
|
else if (firstTokenTime === undefined && OUTPUT_CHUNKS.has(chunk.type)) firstTokenTime = Date.now();
|
|
79
86
|
// OpenRouter reports what it charged; the finish of its response carries it.
|
|
80
87
|
if (chunk.type === 'finish' && chunk.replayState?.response?.kind === REPLAY_KIND && Number.isFinite(chunk.replayState.response.cost)) billed = chunk.replayState.response.cost;
|
|
81
|
-
if (liveSession) liveRate.add(liveSession, chunk);
|
|
82
88
|
yield chunk;
|
|
83
89
|
}
|
|
90
|
+
completed = true;
|
|
84
91
|
} finally {
|
|
85
|
-
|
|
92
|
+
const endTime = Date.now();
|
|
93
|
+
if (liveSession) activeRequests.set(liveSession, (activeRequests.get(liveSession) ?? 1) - 1);
|
|
94
|
+
if (completed) settleRate?.({ start: time, end: endTime, outputTokens: usage?.outputTokens });
|
|
86
95
|
// `time` stays the start (it prices the call); `endTime` and `firstTokenTime` time it.
|
|
87
|
-
save({ kind: 'end', id, time, endTime
|
|
96
|
+
save({ kind: 'end', id, time, endTime, ...(firstTokenTime === undefined ? {} : { firstTokenTime }), usage: usage ?? null, ...(billed === undefined
|
|
88
97
|
? { cost: estimateCost(options.provider, options.model, usage, time), priceVersion: priceVersionFor(options.provider, options.model) }
|
|
89
98
|
: { cost: billed, priceVersion: 'openrouter-billed' }) });
|
|
90
99
|
}
|
|
@@ -1,69 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// the current output burst: the next chunk starts a fresh window instead of
|
|
4
|
-
// averaging over the silence.
|
|
5
|
-
const GAP_MS = 1500;
|
|
6
|
-
const MIN_SPAN_MS = 500;
|
|
7
|
-
|
|
8
|
-
// Providers report exact output tokens only when a request settles. During
|
|
9
|
-
// streaming, estimate from UTF-8 bytes without rounding each small chunk.
|
|
10
|
-
export function estimatedDeltaTokens(chunk) {
|
|
11
|
-
const text = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta'
|
|
12
|
-
? chunk.text
|
|
13
|
-
: chunk.type === 'tool-call-delta' ? chunk.argumentsDelta : undefined;
|
|
14
|
-
return typeof text === 'string' ? Buffer.byteLength(text, 'utf8') / 4 : 0;
|
|
15
|
-
}
|
|
1
|
+
// Each valid completed request halves every older sample's weight.
|
|
2
|
+
const DECAY = 0.5;
|
|
16
3
|
|
|
17
4
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
5
|
+
* Normalized exponential average of per-request TPS, from final API usage.
|
|
6
|
+
* New samples have weight 1; existing weights multiply by DECAY. Normalizing
|
|
7
|
+
* the startup weights keeps newer requests more influential from sample two.
|
|
8
|
+
* Idle time and unusable samples do not age the history.
|
|
21
9
|
*/
|
|
22
|
-
export function
|
|
23
|
-
const
|
|
24
|
-
const stateOf = session => {
|
|
25
|
-
let state = samples.get(session);
|
|
26
|
-
if (!state) { state = { values: [], head: 0, sum: 0, factor: 1, pending: 0 }; samples.set(session, state); }
|
|
27
|
-
return state;
|
|
28
|
-
};
|
|
29
|
-
const prune = (state, now) => {
|
|
30
|
-
while (state.head < state.values.length && state.values[state.head].time <= now - WINDOW_MS) {
|
|
31
|
-
state.sum -= state.values[state.head++].tokens;
|
|
32
|
-
}
|
|
33
|
-
if (state.head > 128 && state.head * 2 > state.values.length) {
|
|
34
|
-
state.values.splice(0, state.head);
|
|
35
|
-
state.head = 0;
|
|
36
|
-
}
|
|
37
|
-
};
|
|
10
|
+
export function createSmoothedRate() {
|
|
11
|
+
const sessions = new WeakMap();
|
|
38
12
|
return {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const pending = state.pending;
|
|
55
|
-
state.pending = 0;
|
|
56
|
-
if (!(pending > 0) || !Number.isFinite(outputTokens) || outputTokens <= 0) return;
|
|
57
|
-
const ratio = Math.min(2, Math.max(0.5, outputTokens / pending));
|
|
58
|
-
state.factor = state.factor * 0.5 + ratio * 0.5;
|
|
13
|
+
begin(session, route) {
|
|
14
|
+
let state = sessions.get(session);
|
|
15
|
+
if (!state || state.route !== route) {
|
|
16
|
+
state = { route, weightedRate: 0, weight: 0 };
|
|
17
|
+
sessions.set(session, state);
|
|
18
|
+
}
|
|
19
|
+
// A late completion from an older model must not contaminate the new one.
|
|
20
|
+
return ({ start, end, outputTokens }) => {
|
|
21
|
+
if (sessions.get(session) !== state || !Number.isFinite(start) || !Number.isFinite(end)
|
|
22
|
+
|| end <= start || !Number.isFinite(outputTokens) || outputTokens < 0) return;
|
|
23
|
+
const rate = outputTokens / ((end - start) / 1000);
|
|
24
|
+
if (!Number.isFinite(rate)) return;
|
|
25
|
+
state.weightedRate = state.weightedRate * DECAY + rate;
|
|
26
|
+
state.weight = state.weight * DECAY + 1;
|
|
27
|
+
};
|
|
59
28
|
},
|
|
60
|
-
get(session
|
|
61
|
-
const state =
|
|
62
|
-
|
|
63
|
-
prune(state, now);
|
|
64
|
-
if (state.head >= state.values.length) return 0;
|
|
65
|
-
const span = Math.min(WINDOW_MS, Math.max(MIN_SPAN_MS, now - state.values[state.head].time));
|
|
66
|
-
return Math.max(0, state.sum) * state.factor / (span / 1000);
|
|
29
|
+
get(session) {
|
|
30
|
+
const state = sessions.get(session);
|
|
31
|
+
return state?.weight > 0 ? state.weightedRate / state.weight : null;
|
|
67
32
|
},
|
|
68
33
|
};
|
|
69
34
|
}
|
|
@@ -111,7 +111,7 @@ function resetStamp(iso, now) {
|
|
|
111
111
|
* terminal width decides which segments the drop ladder keeps.
|
|
112
112
|
*/
|
|
113
113
|
const FIGURE = {
|
|
114
|
-
/**
|
|
114
|
+
/** Request and session-average rates share the same fixed figure width. */
|
|
115
115
|
rate: 6,
|
|
116
116
|
/** `100%` context occupancy. */
|
|
117
117
|
percent: 4,
|
|
@@ -135,7 +135,7 @@ function figure(text, width) {
|
|
|
135
135
|
return padding > 0 ? ' '.repeat(padding) + text : text;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
function footerFigures(metrics, context, rates, locale = 'en', provider = 'deepseek-official') {
|
|
139
139
|
const label = key => t(locale, key);
|
|
140
140
|
const ctx = figure(Number.isFinite(context) ? `${Math.round(context)}%` : '--', FIGURE.percent);
|
|
141
141
|
const cache = figure(metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`, FIGURE.share);
|
|
@@ -161,13 +161,21 @@ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en
|
|
|
161
161
|
// priceable, the same mark the total uses.
|
|
162
162
|
const turn = metrics.lastTurn === undefined || !Number.isFinite(metrics.lastTurn.cost) ? ''
|
|
163
163
|
: `#${metrics.lastTurn.turn} $${metrics.lastTurn.cost.toFixed(2)}${metrics.lastTurn.unknown ? '+' : ''}`;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
`${
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
return {
|
|
165
|
+
current: rates ? `${rates.active ? '◌' : ' '} ${figure(Number.isFinite(rates.current) ? rates.current.toFixed(1) : '--', FIGURE.rate)} tps` : '',
|
|
166
|
+
average: rates ? `${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps ${label('footer.average')}` : '',
|
|
167
|
+
context: `${ctx} ${label('footer.context')}`,
|
|
168
|
+
money,
|
|
169
|
+
cache: `${cache} ${label('footer.cache')}`,
|
|
170
|
+
turn,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Flat footer for text consumers; the TUI uses the same figures as separate groups. */
|
|
175
|
+
export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', provider = 'deepseek-official') {
|
|
176
|
+
const figures = footerFigures(metrics, context, rates, locale, provider);
|
|
177
|
+
const { current, average, context: ctx, money, cache, turn } = figures;
|
|
178
|
+
const base = rates ? [current, average, ctx, money, cache] : [ctx, money, cache];
|
|
171
179
|
// The slot is reserved even when no turn has cost anything yet: the drop
|
|
172
180
|
// ladder's indices are fixed against this array, and an unfilled slot renders
|
|
173
181
|
// as '' (skipped by `render`) exactly like the absent figure would.
|
|
@@ -204,7 +212,7 @@ export function sessionSpend(id) {
|
|
|
204
212
|
|
|
205
213
|
/** Per-events memo: the status line renders up to once a second, and summarize/average are O(events). */
|
|
206
214
|
const footerCache = new WeakMap();
|
|
207
|
-
export function footerFor(id, stats, columns, provider = 'deepseek-official', locale = 'en') {
|
|
215
|
+
export function footerFor(id, stats, columns, provider = 'deepseek-official', locale = 'en', format = formatFooter) {
|
|
208
216
|
const limit = parseBudget(process.env.DSCODE_SESSION_BUDGET_USD);
|
|
209
217
|
try {
|
|
210
218
|
const data = id ? source?.(id) : undefined;
|
|
@@ -222,6 +230,12 @@ export function footerFor(id, stats, columns, provider = 'deepseek-official', lo
|
|
|
222
230
|
// The budget comes from the environment for this process only: it is a
|
|
223
231
|
// per-machine spending guard, not a session property worth persisting.
|
|
224
232
|
const budget = limit === null ? undefined : evaluateBudget(summary.cost, limit);
|
|
225
|
-
return
|
|
226
|
-
} catch { return
|
|
233
|
+
return format({ ...summary, ...(budget === undefined ? {} : { budget }) }, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average, active: data?.requestActive }, locale, provider);
|
|
234
|
+
} catch { return format({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, provider); }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Structured figures preserve money/cache as one right-aligned group in the TUI. */
|
|
238
|
+
export function footerFiguresFor(id, stats, provider = 'deepseek-official', locale = 'en') {
|
|
239
|
+
return footerFor(id, stats, 0, provider, locale,
|
|
240
|
+
(metrics, context, _columns, rates, language, route) => footerFigures(metrics, context, rates, language, route));
|
|
227
241
|
}
|
package/plugins/triggers/cli.mjs
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// injected, which is also what makes the whole command testable.
|
|
9
9
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
13
|
+
import { acquireTriggerLease } from './lease.mjs';
|
|
14
|
+
import { handleJobCommand, JOB_COMMANDS, executeJob } from './job-cli.mjs';
|
|
15
|
+
import { emitToSource } from './source-ingress.mjs';
|
|
16
|
+
import { JobStore } from './jobs.mjs';
|
|
17
|
+
import { schedulerService } from './scheduler-service.mjs';
|
|
11
18
|
import { dirname, join, resolve } from 'node:path';
|
|
12
19
|
import { parse as parseYaml } from 'yaml';
|
|
13
20
|
import { loadTriggerDefinitions, formatTrigger } from './config.mjs';
|
|
@@ -23,17 +30,25 @@ export const USAGE = `Usage: dscode trigger <command> [options]
|
|
|
23
30
|
Commands:
|
|
24
31
|
run <id> Start one run now (respecting the trigger's limits)
|
|
25
32
|
fire <id> Post an event and run it: --text "..." or --event FILE
|
|
26
|
-
emit <id>
|
|
33
|
+
emit <id> Commit an event to the scheduler queue
|
|
27
34
|
events [id] Print the events waiting in a trigger's spool
|
|
28
35
|
list List the trigger definitions and their last outcome
|
|
29
36
|
show <id> Print one definition
|
|
30
37
|
log [id] Print recorded runs, newest first (--failed for failures only)
|
|
31
38
|
new <id> Write a starter definition into the project
|
|
32
39
|
enable|disable <id> Set the definition's enabled flag in its own file
|
|
33
|
-
|
|
40
|
+
schedule <id> Schedule one event: --after 30m or --at ISO_TIMESTAMP
|
|
41
|
+
jobs [id] List scheduled jobs and their state
|
|
42
|
+
cancel <jobId> Cancel a pending job (never stops a running one)
|
|
43
|
+
source <id> <action> status | start | stop | restart | logs
|
|
44
|
+
scheduler <action> start | tick | install | uninstall | status
|
|
45
|
+
run-job <jobId> Execute one due job (used by the scheduler)
|
|
46
|
+
install <id> Register a recurring source and install the scheduler
|
|
34
47
|
uninstall <id> Remove the schedule
|
|
35
48
|
|
|
36
49
|
Options:
|
|
50
|
+
--after DURATION Delay such as 30s, 10m, 2h or 1d (schedule only)
|
|
51
|
+
--at TIMESTAMP ISO timestamp with an explicit offset (schedule only)
|
|
37
52
|
--event FILE Event body as JSON; "-" reads stdin
|
|
38
53
|
--text TEXT Event body text (with "fire"/"emit")
|
|
39
54
|
--event-id ID Identity of the posted event; retry with the same one
|
|
@@ -45,7 +60,7 @@ Exit codes are the run's: 0 completed, skipped or nothing to do; 1 failed;
|
|
|
45
60
|
2 round or cost cap; 3 the goal is blocked or paused; 124 timeout; 130 interrupted.
|
|
46
61
|
An orderly stop writes a result file; only a Host that died leaves none behind.`;
|
|
47
62
|
|
|
48
|
-
const COMMANDS = ['run', 'fire', 'emit', 'events', 'list', 'show', 'log', 'new', 'enable', 'disable', 'install', 'uninstall'];
|
|
63
|
+
const COMMANDS = ['source', 'run', 'fire', 'emit', 'events', 'list', 'show', 'log', 'new', 'enable', 'disable', 'install', 'uninstall', ...JOB_COMMANDS];
|
|
49
64
|
|
|
50
65
|
/** Parse the arguments after `trigger`. */
|
|
51
66
|
export function parseTriggerArgs(argv) {
|
|
@@ -54,16 +69,18 @@ export function parseTriggerArgs(argv) {
|
|
|
54
69
|
options.command = words.shift() ?? '';
|
|
55
70
|
if (options.command === '' || options.command === '--help' || options.command === '-h') { options.help = true; return options; }
|
|
56
71
|
if (!COMMANDS.includes(options.command)) { options.error = `unknown command "${options.command}"`; return options; }
|
|
57
|
-
if (['list', 'log'].includes(options.command)) {
|
|
72
|
+
if (['list', 'log', 'events', 'jobs'].includes(options.command)) {
|
|
58
73
|
if (words.length > 0 && !words[0].startsWith('-')) options.id = words.shift();
|
|
59
74
|
} else {
|
|
60
75
|
const id = words.shift() ?? '';
|
|
61
76
|
if (id === '' || id.startsWith('-')) { options.error = `${options.command} needs a trigger id`; return options; }
|
|
62
77
|
options.id = id;
|
|
63
78
|
}
|
|
79
|
+
if (options.command === 'source') options.action = words.shift() ?? 'status';
|
|
64
80
|
while (words.length > 0) {
|
|
65
81
|
const flag = words.shift();
|
|
66
82
|
const value = () => { const next = words.shift(); if (next === undefined) { options.error = `${flag} needs a value`; return undefined; } return next; };
|
|
83
|
+
if (flag === '--after' || flag === '--at') { const next = value(); if (next === undefined) return options; options[flag.slice(2)] = next; continue; }
|
|
67
84
|
if (flag === '--event') { const next = value(); if (next === undefined) return options; options.event = next; continue; }
|
|
68
85
|
if (flag === '--text') { const next = value(); if (next === undefined) return options; options.text = next; continue; }
|
|
69
86
|
if (flag === '--event-id') { const next = value(); if (next === undefined) return options; options.eventId = next; continue; }
|
|
@@ -74,6 +91,7 @@ export function parseTriggerArgs(argv) {
|
|
|
74
91
|
options.error = `unknown option "${flag}"`;
|
|
75
92
|
return options;
|
|
76
93
|
}
|
|
94
|
+
if (options.command !== 'schedule' && (options.after !== undefined || options.at !== undefined)) options.error = '--after and --at are only accepted by schedule';
|
|
77
95
|
return options;
|
|
78
96
|
}
|
|
79
97
|
|
|
@@ -112,6 +130,7 @@ export function scaffoldTrigger(id, { workspace }) {
|
|
|
112
130
|
`workspace: ${workspace}`,
|
|
113
131
|
'prompt: describe what this run should do',
|
|
114
132
|
'source: { kind: interval, seconds: 300 }',
|
|
133
|
+
'session: { mode: new }',
|
|
115
134
|
'goal: { objective: describe the finished state, maxRounds: 20 }',
|
|
116
135
|
'limits: { timeoutSeconds: 1800, maxRunsPerDay: 24, minIntervalSeconds: 60 }',
|
|
117
136
|
'',
|
|
@@ -257,6 +276,28 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
257
276
|
// A CLI function returns an exit code: an unknown id, an unreadable definition
|
|
258
277
|
// or a missing workspace is a user-facing message, not a thrown stack.
|
|
259
278
|
try {
|
|
279
|
+
if (process.env.DSCODE_SOURCE_SOCKET) {
|
|
280
|
+
if (options.command !== 'emit') throw new Error('script source ingress only supports emit');
|
|
281
|
+
const payload = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
282
|
+
out(JSON.stringify(await emitToSource({ triggerId: options.id, eventId: options.eventId, payload })));
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
if (options.command === 'source') {
|
|
286
|
+
const definition = findDefinition(home, project, options.id);
|
|
287
|
+
if (definition.source.kind !== 'script') throw new Error('source management requires a script source');
|
|
288
|
+
const store = new JobStore(home);
|
|
289
|
+
try {
|
|
290
|
+
if (['status', 'logs'].includes(options.action)) {
|
|
291
|
+
const source = store.source(definition.id, project);
|
|
292
|
+
out(options.action === 'logs' ? source?.log || '(no source output)' : JSON.stringify(source ?? { status: 'unregistered' }));
|
|
293
|
+
} else { out(JSON.stringify(store.controlSource(definition, project, options.action))); out('The shared scheduler must be running to supervise sources.'); }
|
|
294
|
+
} finally { store.close(); }
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
if (JOB_COMMANDS.includes(options.command)) return await handleJobCommand(options, {
|
|
298
|
+
home, project, now, platform, dscodePath, launchctl, out, err, deps,
|
|
299
|
+
findDefinition, readEventBody, executeEvent,
|
|
300
|
+
});
|
|
260
301
|
if (options.command === 'list') {
|
|
261
302
|
const { definitions, problems } = loadTriggerDefinitions({ home, workspace: project });
|
|
262
303
|
if (definitions.length === 0 && problems.length === 0) { out('No triggers defined.'); return 0; }
|
|
@@ -277,15 +318,23 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
277
318
|
return 0;
|
|
278
319
|
}
|
|
279
320
|
if (options.command === 'events') {
|
|
321
|
+
let durableCount = 0;
|
|
322
|
+
const store = new JobStore(home);
|
|
323
|
+
try {
|
|
324
|
+
for (const job of store.list(options.id || undefined).filter(j => j.project === project && j.kind === 'event' && ['pending', 'running'].includes(j.state))) {
|
|
325
|
+
durableCount += 1;
|
|
326
|
+
out(`${job.triggerId} ${job.state} ${store.eventIdentity(job.id)} ${JSON.parse(job.payload).text}`);
|
|
327
|
+
}
|
|
328
|
+
} finally { store.close(); }
|
|
280
329
|
if (options.id === '') {
|
|
281
330
|
const { definitions } = loadTriggerDefinitions({ home, workspace: project });
|
|
282
331
|
let total = 0;
|
|
283
332
|
for (const definition of definitions) for (const event of listEvents(home, definition.id)) { out(`${definition.id} ${formatEvent(event)}`); total += 1; }
|
|
284
|
-
if (total === 0) out('No pending events.');
|
|
333
|
+
if (total === 0 && durableCount === 0) out('No pending events.');
|
|
285
334
|
return 0;
|
|
286
335
|
}
|
|
287
336
|
const pending = listEvents(home, options.id);
|
|
288
|
-
if (pending.length === 0) out(`No pending events for ${options.id}.`);
|
|
337
|
+
if (pending.length === 0 && durableCount === 0) out(`No pending events for ${options.id}.`);
|
|
289
338
|
else for (const event of pending) out(formatEvent(event));
|
|
290
339
|
return 0;
|
|
291
340
|
}
|
|
@@ -314,6 +363,8 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
314
363
|
const definition = findDefinition(home, project, options.id);
|
|
315
364
|
const path = agentPath(home, definition.id);
|
|
316
365
|
if (options.command === 'uninstall') {
|
|
366
|
+
const store = new JobStore(home);
|
|
367
|
+
try { store.unregister(definition.id, project, now); } finally { store.close(); }
|
|
317
368
|
if (launchctl !== undefined && platform === 'darwin') await launchctl(['bootout', `gui/${process.getuid?.() ?? ''}/${'ai.dscode.trigger.' + definition.id}`], { ignoreFailure: true });
|
|
318
369
|
rmSync(path, { force: true });
|
|
319
370
|
out(`removed the schedule for ${options.id} (${path})`);
|
|
@@ -332,6 +383,15 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
332
383
|
err(`cannot install ${options.id}: ${dscodePath} is not an executable file, and launchd runs no shell profile`);
|
|
333
384
|
return 1;
|
|
334
385
|
}
|
|
386
|
+
if (['calendar', 'interval', 'poll', 'script'].includes(definition.source.kind)) {
|
|
387
|
+
const store = new JobStore(home);
|
|
388
|
+
try { store.register(definition, project, now); } finally { store.close(); }
|
|
389
|
+
if (platform === 'darwin' && launchctl) await launchctl(['bootout', `gui/${process.getuid()}/ai.dscode.trigger.${definition.id}`], { ignoreFailure: true });
|
|
390
|
+
rmSync(path, { force: true });
|
|
391
|
+
out(`registered ${definition.id} with the scheduler`);
|
|
392
|
+
await schedulerService('install', { home, dscodePath, platform, launchctl, agentsDirectory: deps.agentsDirectory, out });
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
335
395
|
const plist = launchAgent(definition, { home, dscodePath, project });
|
|
336
396
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
337
397
|
writeFileSync(path, plist, { mode: 0o600 });
|
|
@@ -350,14 +410,26 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
350
410
|
const definition = findDefinition(home, project, options.id);
|
|
351
411
|
if (options.command === 'emit') {
|
|
352
412
|
const body = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
353
|
-
const
|
|
354
|
-
|
|
413
|
+
const store = new JobStore(home);
|
|
414
|
+
try {
|
|
415
|
+
const job = store.acceptEvent({ definition, project, payload: { source: 'cli', ...body }, eventId: options.eventId ?? deps.eventId ?? `manual-${randomUUID()}`, now });
|
|
416
|
+
out(`queued ${store.eventIdentity(job.id)} to ${definition.id}: ${job.id} (${job.state})`);
|
|
417
|
+
out('Delivery requires the shared scheduler, or dscode trigger run ' + definition.id);
|
|
418
|
+
} finally { store.close(); }
|
|
355
419
|
return 0;
|
|
356
420
|
}
|
|
421
|
+
if (options.command === 'run') {
|
|
422
|
+
const store = new JobStore(home);
|
|
423
|
+
try {
|
|
424
|
+
const wanted = options.eventId ?? deps.eventId;
|
|
425
|
+
const job = store.list(definition.id).reverse().find(j => j.project === project && j.kind === 'event' && (wanted === undefined ? j.state === 'pending' : store.eventIdentity(j.id) === wanted));
|
|
426
|
+
if (job) return await executeJob(store, job.id, { home, project, now, deps, out, err, executeEvent, findDefinition });
|
|
427
|
+
} finally { store.close(); }
|
|
428
|
+
}
|
|
357
429
|
let fired;
|
|
358
430
|
if (options.command === 'fire') {
|
|
359
431
|
const body = (await readEventBody(options, deps.stdin ?? process.stdin)) ?? {};
|
|
360
|
-
fired = emitEvent(home, definition.id, { source: 'cli', ...body }, { eventId: options.eventId ?? deps.eventId ?? `fire-${
|
|
432
|
+
fired = emitEvent(home, definition.id, { source: 'cli', ...body }, { eventId: options.eventId ?? deps.eventId ?? `fire-${randomUUID()}`, now });
|
|
361
433
|
}
|
|
362
434
|
|
|
363
435
|
const pending = listEvents(home, definition.id);
|
|
@@ -369,21 +441,80 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
369
441
|
err(`no pending event "${wanted}" for ${definition.id}; post it with "emit", or omit --event-id`);
|
|
370
442
|
return 1;
|
|
371
443
|
}
|
|
372
|
-
const
|
|
444
|
+
const scheduled = definition.session.mode === 'persistent' && definition.source.kind !== 'external' && options.command === 'run' && wanted === undefined;
|
|
445
|
+
const chosen = fired ?? matching ?? (!scheduled && wanted === undefined ? pending[0] : undefined);
|
|
373
446
|
const eventId = chosen?.eventId ?? wanted ?? firingIdentity(definition, now);
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
447
|
+
const persistent = definition.session.mode === 'persistent';
|
|
448
|
+
// Scheduled invocations also become durable events when a session is reused.
|
|
449
|
+
// An explicit external drain processes the backlog without inventing a tick.
|
|
450
|
+
if (persistent && chosen === undefined) {
|
|
451
|
+
emitEvent(home, definition.id, { source: definition.source.kind }, { eventId, now });
|
|
378
452
|
}
|
|
453
|
+
const triggerLease = await acquireTriggerLease(home, definition.id, { wait: persistent });
|
|
454
|
+
if (!triggerLease) {
|
|
455
|
+
err(`skipped ${definition.id}: already_running`);
|
|
456
|
+
return recordSkip(home, definition, { reason: 'already_running', eventId, now });
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
if (!persistent) return (await executeEvent(definition, { home, now, eventId, chosen, deps, out, err, triggerLease })).code;
|
|
460
|
+
let exitCode = 0;
|
|
461
|
+
const clock = deps.clock ?? (() => deps.now ?? Date.now());
|
|
462
|
+
for (;;) {
|
|
463
|
+
const next = listEvents(home, definition.id, { limit: 1 })[0];
|
|
464
|
+
if (!next) {
|
|
465
|
+
const own = readRuns(home, { triggerId: definition.id, limit: 0 }).find(run => run.eventId === eventId && run.outcome !== 'skipped');
|
|
466
|
+
return exitCode || own?.exitCode || 0;
|
|
467
|
+
}
|
|
468
|
+
const current = findDefinition(home, project, definition.id);
|
|
469
|
+
if (current.session.mode !== 'persistent') return exitCode;
|
|
470
|
+
const result = await executeEvent(current, { home, now: clock(), eventId: next.eventId, chosen: next, deps, out, err, triggerLease });
|
|
471
|
+
if (result.skip === 'duplicate') {
|
|
472
|
+
consumeEvent(home, definition.id, next.eventId);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (result.skip === 'too_soon') {
|
|
476
|
+
const previous = readRuns(home, { triggerId: definition.id, limit: 0 }).find(run => run.outcome !== 'skipped');
|
|
477
|
+
const remaining = previous.startedAt + current.limits.minIntervalSeconds * 1000 - clock();
|
|
478
|
+
await (deps.delay ?? sleep)(Math.max(1, Math.min(remaining, 1000)));
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (result.skip) return exitCode; // disabled, daily cap, or a legacy run: leave events pending
|
|
482
|
+
if (result.code !== 0) exitCode = result.code;
|
|
483
|
+
if (result.code === 130) return exitCode;
|
|
484
|
+
}
|
|
485
|
+
} finally { triggerLease.release(); }
|
|
486
|
+
} catch (error) {
|
|
487
|
+
err(error?.message ?? String(error));
|
|
488
|
+
return 1;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
379
491
|
|
|
492
|
+
/** The launcher and the source driver both must inject a spawn. */
|
|
493
|
+
function missingSpawn() {
|
|
494
|
+
throw new Error('this build has no way to start a run: inject spawnRun');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Execute one event while the caller holds the cross-process trigger lease. */
|
|
498
|
+
async function executeEvent(definition, { home, now, eventId, chosen, deps, out, err, triggerLease, beforeRun, jobId, quietSkips = false }) {
|
|
499
|
+
const plan = planTriggerRun(definition, { home, now, eventId });
|
|
500
|
+
if (plan.action === 'skip') {
|
|
501
|
+
if (quietSkips || definition.session.mode === 'persistent' && plan.reason === 'too_soon') return { code: 0, skip: plan.reason };
|
|
502
|
+
err(`skipped ${definition.id}: ${plan.reason}`);
|
|
503
|
+
recordSkip(home, definition, { reason: plan.reason, eventId, now });
|
|
504
|
+
return { code: 0, skip: plan.reason };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
if (beforeRun && !beforeRun(plan.handle)) return { code: 0, skip: 'cancelled' };
|
|
380
509
|
// A poll source only runs when its predicate says there is work; without a
|
|
381
510
|
// resident listener this scheduled invocation IS the poll.
|
|
382
511
|
if (definition.source.kind === 'poll') {
|
|
383
|
-
const check = await evaluateCheck(definition.source.check, { cwd: definition.workspace, timeoutMs: 60000, ...(deps.spawnCheck === undefined ? {} : { spawn: deps.spawnCheck }) });
|
|
512
|
+
const check = await evaluateCheck(definition.source.check, { home, permission: definition.permission, cwd: definition.workspace, timeoutMs: 60000, ...(deps.spawnCheck === undefined ? {} : { spawn: deps.spawnCheck }) });
|
|
384
513
|
if (!check.matched) {
|
|
385
514
|
err(`nothing to do for ${definition.id} (${check.code === null ? 'the check did not finish' : `check exited ${check.code}`})`);
|
|
386
|
-
|
|
515
|
+
recordLockedSkip(home, definition, plan.handle, { reason: 'no_match', eventId, details: check.output, now });
|
|
516
|
+
if (chosen !== undefined) consumeEvent(home, definition.id, chosen.eventId);
|
|
517
|
+
return { code: 0 };
|
|
387
518
|
}
|
|
388
519
|
}
|
|
389
520
|
|
|
@@ -391,6 +522,7 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
391
522
|
triggerId: definition.id,
|
|
392
523
|
runId: plan.handle.runId,
|
|
393
524
|
workspace: definition.workspace,
|
|
525
|
+
session: definition.session,
|
|
394
526
|
prompt: renderPrompt(definition.prompt, chosen),
|
|
395
527
|
preset: definition.preset,
|
|
396
528
|
permission: definition.permission,
|
|
@@ -399,17 +531,9 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
399
531
|
goal: { objective: definition.goal.objective, maxRounds: definition.goal.maxRounds },
|
|
400
532
|
limits: definition.limits,
|
|
401
533
|
eventId,
|
|
534
|
+
...(jobId ? { jobId } : {}),
|
|
402
535
|
};
|
|
403
|
-
|
|
404
|
-
let result;
|
|
405
|
-
try {
|
|
406
|
-
({ code, result } = await (deps.spawnRun ?? missingSpawn)({ spec, home, cwd: definition.workspace }));
|
|
407
|
-
} catch (error) {
|
|
408
|
-
// The Host never ran, so the event is still pending and the lock must not
|
|
409
|
-
// outlive the attempt: a later drain should be free to try it again.
|
|
410
|
-
releaseTriggerRun(home, plan.handle);
|
|
411
|
-
throw error;
|
|
412
|
-
}
|
|
536
|
+
const { code, result } = await (deps.spawnRun ?? missingSpawn)({ spec, home, cwd: definition.workspace, triggerLease });
|
|
413
537
|
// Consumed only once the Host has actually run: a failure before that leaves
|
|
414
538
|
// the event pending rather than dropping it.
|
|
415
539
|
if (chosen !== undefined) consumeEvent(home, definition.id, chosen.eventId);
|
|
@@ -424,16 +548,11 @@ export async function runTriggerCli(argv, deps = {}) {
|
|
|
424
548
|
rounds: result?.rounds ?? null,
|
|
425
549
|
eventId,
|
|
426
550
|
endedAt: Date.now(),
|
|
551
|
+
cwd: definition.workspace,
|
|
552
|
+
source: chosen?.source ?? definition.source.kind,
|
|
553
|
+
...(jobId ? { jobId } : {}),
|
|
427
554
|
});
|
|
428
555
|
out(`${definition.id} ${formatRun(record)}${record.sessionId === null ? '' : ` · ${record.sessionId}`}`);
|
|
429
|
-
return record.exitCode;
|
|
430
|
-
}
|
|
431
|
-
err(error?.message ?? String(error));
|
|
432
|
-
return 1;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/** The launcher and the source driver both must inject a spawn. */
|
|
437
|
-
function missingSpawn() {
|
|
438
|
-
throw new Error('this build has no way to start a run: inject spawnRun');
|
|
556
|
+
return { code: record.exitCode, record };
|
|
557
|
+
} finally { releaseTriggerRun(home, plan.handle); }
|
|
439
558
|
}
|