@toddzheng024/dscode-bundle 0.7.24 → 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,156 @@
|
|
|
1
|
+
// The schedule cursor and generated job commit together. Claim/cancel are SQL
|
|
2
|
+
// compare-and-swap transitions; a running job is never automatically retried.
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { chmodSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { normalizeEvent } from './spool.mjs';
|
|
8
|
+
import { nextFiring, dueFiring } from './schedule.mjs';
|
|
9
|
+
|
|
10
|
+
const key = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
11
|
+
export class JobStore {
|
|
12
|
+
constructor(home) {
|
|
13
|
+
const directory = join(home, 'triggers');
|
|
14
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
15
|
+
const path = join(directory, 'jobs.sqlite');
|
|
16
|
+
this.db = new DatabaseSync(path);
|
|
17
|
+
chmodSync(path, 0o600);
|
|
18
|
+
this.db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
19
|
+
CREATE TABLE IF NOT EXISTS schedules (id TEXT PRIMARY KEY, triggerId TEXT NOT NULL, project TEXT NOT NULL, workspace TEXT NOT NULL, source TEXT NOT NULL, nextAt INTEGER NOT NULL, enabled INTEGER NOT NULL DEFAULT 1);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS sources (id TEXT PRIMARY KEY, triggerId TEXT NOT NULL, project TEXT NOT NULL, desired TEXT NOT NULL DEFAULT 'running', revision INTEGER NOT NULL DEFAULT 0, nextAt INTEGER NOT NULL DEFAULT 0, failures INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'waiting', pid INTEGER, error TEXT, log TEXT NOT NULL DEFAULT '');
|
|
21
|
+
CREATE TABLE IF NOT EXISTS event_receipts (id TEXT PRIMARY KEY, eventId TEXT NOT NULL, request TEXT NOT NULL);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS job_requests (id TEXT PRIMARY KEY, request TEXT NOT NULL);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS jobs (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE NOT NULL, triggerId TEXT NOT NULL, project TEXT NOT NULL, workspace TEXT NOT NULL, kind TEXT NOT NULL, scheduleId TEXT, dueAt INTEGER NOT NULL, availableAt INTEGER NOT NULL, createdAt INTEGER NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending', reason TEXT, runId TEXT, sessionId TEXT, pid INTEGER, startedAt INTEGER, endedAt INTEGER, exitCode INTEGER);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS jobs_due ON jobs(state,availableAt,dueAt,seq);`);
|
|
25
|
+
}
|
|
26
|
+
close() { this.db.close(); }
|
|
27
|
+
all(sql, ...args) { return this.db.prepare(sql).all(...args); }
|
|
28
|
+
one(sql, ...args) { return this.db.prepare(sql).get(...args); }
|
|
29
|
+
run(sql, ...args) { return this.db.prepare(sql).run(...args); }
|
|
30
|
+
transaction(fn) {
|
|
31
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
32
|
+
try { const result = fn(); this.db.exec('COMMIT'); return result; }
|
|
33
|
+
catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
34
|
+
}
|
|
35
|
+
get(id) { return this.one('SELECT * FROM jobs WHERE id=?', id); }
|
|
36
|
+
list(triggerId) { return this.all(`SELECT * FROM jobs ${triggerId ? 'WHERE triggerId=?' : ''} ORDER BY dueAt DESC,seq DESC`, ...(triggerId ? [triggerId] : [])); }
|
|
37
|
+
pending(now) { return this.all(`SELECT j.* FROM jobs j WHERE j.state='pending' AND j.availableAt<=?
|
|
38
|
+
AND NOT EXISTS (SELECT 1 FROM jobs older WHERE older.triggerId=j.triggerId AND older.state='pending' AND older.dueAt<=?
|
|
39
|
+
AND (older.dueAt<j.dueAt OR (older.dueAt=j.dueAt AND older.seq<j.seq))) ORDER BY j.dueAt,j.seq`, now, now); }
|
|
40
|
+
running() { return this.all("SELECT * FROM jobs WHERE state='running'"); }
|
|
41
|
+
create({ triggerId, project, workspace, payload = {}, dueAt, kind = 'delay', scheduleId = null, id = `job-${randomUUID()}`, now = Date.now() }) {
|
|
42
|
+
this.run('INSERT OR IGNORE INTO jobs(id,triggerId,project,workspace,kind,scheduleId,dueAt,availableAt,createdAt,payload) VALUES(?,?,?,?,?,?,?,?,?,?)', id, triggerId, project, workspace, kind, scheduleId, dueAt, dueAt, now, JSON.stringify(payload));
|
|
43
|
+
return this.get(id);
|
|
44
|
+
}
|
|
45
|
+
acceptEvent({ definition, project, payload, eventId, now = Date.now(), maxPending = 100 }) {
|
|
46
|
+
const event = normalizeEvent(definition.id, payload, { eventId, now });
|
|
47
|
+
const { triggerId, receivedAt: _received, eventId: identity, ...body } = event;
|
|
48
|
+
const request = JSON.stringify(body, (_k, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v);
|
|
49
|
+
if (Buffer.byteLength(request) > 131072) throw new Error('event body exceeds 128 KiB');
|
|
50
|
+
const id = `event-${key([project, triggerId, identity])}`;
|
|
51
|
+
return this.transaction(() => {
|
|
52
|
+
const receipt = this.one('SELECT * FROM event_receipts WHERE id=?', id);
|
|
53
|
+
if (receipt) {
|
|
54
|
+
if (receipt.request !== request) throw new Error('eventId was already used for a different payload');
|
|
55
|
+
return this.get(id);
|
|
56
|
+
}
|
|
57
|
+
const count = this.one("SELECT COUNT(*) AS n FROM jobs WHERE project=? AND triggerId=? AND state IN ('pending','running')", project, triggerId).n;
|
|
58
|
+
if (count >= maxPending) throw new Error('QUEUE_FULL: retry this event later with the same eventId');
|
|
59
|
+
this.run('INSERT INTO event_receipts(id,eventId,request) VALUES(?,?,?)', id, identity, request);
|
|
60
|
+
return this.create({ id, triggerId, project, workspace: definition.workspace, payload: body, kind: 'event', dueAt: now, now });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
eventIdentity(id) { return this.one('SELECT eventId FROM event_receipts WHERE id=?', id)?.eventId; }
|
|
64
|
+
sources() { return this.all('SELECT * FROM sources'); }
|
|
65
|
+
source(triggerId, project) { return this.one('SELECT * FROM sources WHERE id=?', key([project, triggerId])); }
|
|
66
|
+
registerSource(definition, project) {
|
|
67
|
+
if (definition.source.kind !== 'script') throw new Error('source management requires a script source');
|
|
68
|
+
const id = key([project, definition.id]);
|
|
69
|
+
this.run('INSERT OR IGNORE INTO sources(id,triggerId,project) VALUES(?,?,?)', id, definition.id, project);
|
|
70
|
+
return this.source(definition.id, project);
|
|
71
|
+
}
|
|
72
|
+
controlSource(definition, project, action) {
|
|
73
|
+
if (!['start', 'stop', 'restart'].includes(action)) throw new Error('source expects status, start, stop, restart or logs');
|
|
74
|
+
const saved = this.registerSource(definition, project);
|
|
75
|
+
this.run("UPDATE sources SET desired=?,revision=revision+?,nextAt=0,failures=0 WHERE id=?", action === 'stop' ? 'stopped' : 'running', Number(action === 'restart'), saved.id);
|
|
76
|
+
return this.source(definition.id, project);
|
|
77
|
+
}
|
|
78
|
+
cancel(id, now = Date.now()) {
|
|
79
|
+
if (!this.get(id)) throw new Error(`no job "${id}"`);
|
|
80
|
+
if (!this.run("UPDATE jobs SET state='cancelled',reason='cancelled',endedAt=? WHERE id=? AND state='pending'", now, id).changes) {
|
|
81
|
+
throw new Error(`job ${id} is ${this.get(id).state}; only pending jobs can be cancelled`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Tool retries reuse the first due time; a changed request cannot reuse its key. */
|
|
85
|
+
createOnce({ requestKey, request, ...options }) {
|
|
86
|
+
const id = `job-${key([options.project, requestKey])}`;
|
|
87
|
+
return this.transaction(() => {
|
|
88
|
+
const saved = this.one('SELECT request FROM job_requests WHERE id=?', id);
|
|
89
|
+
if (saved) {
|
|
90
|
+
if (saved.request !== request) throw new Error('idempotency_key was already used for a different job request');
|
|
91
|
+
return this.get(id);
|
|
92
|
+
}
|
|
93
|
+
this.run('INSERT INTO job_requests(id,request) VALUES(?,?)', id, request);
|
|
94
|
+
return this.create({ ...options, dueAt: typeof options.dueAt === 'function' ? options.dueAt() : options.dueAt, id });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
defer(id, reason, availableAt) { this.run("UPDATE jobs SET reason=?,availableAt=? WHERE id=? AND state='pending'", reason, availableAt, id); }
|
|
98
|
+
claim(id, runId, now) { return this.run("UPDATE jobs SET state='running',reason=NULL,runId=?,pid=?,startedAt=? WHERE id=? AND state='pending' AND availableAt<=?", runId, process.pid, now, id, now).changes === 1; }
|
|
99
|
+
finish(id, result, now = Date.now()) {
|
|
100
|
+
const state = result.exitCode === 0 ? 'completed' : 'failed';
|
|
101
|
+
this.run("UPDATE jobs SET state=?,reason=?,sessionId=?,endedAt=?,exitCode=? WHERE id=? AND state='running'", state, result.reason ?? null, result.sessionId ?? null, now, result.exitCode, id);
|
|
102
|
+
}
|
|
103
|
+
schedules() { return this.all('SELECT * FROM schedules'); }
|
|
104
|
+
register(definition, project, now = Date.now()) {
|
|
105
|
+
if (definition.source.kind === 'script') {
|
|
106
|
+
this.run('DELETE FROM schedules WHERE id=?', key([project, definition.id]));
|
|
107
|
+
return this.registerSource(definition, project);
|
|
108
|
+
}
|
|
109
|
+
this.run('DELETE FROM sources WHERE id=?', key([project, definition.id]));
|
|
110
|
+
if (!['calendar', 'interval', 'poll'].includes(definition.source.kind)) throw new Error('only calendar, interval and poll sources have recurring jobs');
|
|
111
|
+
const id = key([project, definition.id]);
|
|
112
|
+
const source = JSON.stringify(definition.source);
|
|
113
|
+
const old = this.one('SELECT * FROM schedules WHERE id=?', id);
|
|
114
|
+
if (old?.workspace === definition.workspace && old.source === source) return old;
|
|
115
|
+
const nextAt = nextFiring(definition.source, now);
|
|
116
|
+
this.run('INSERT INTO schedules(id,triggerId,project,workspace,source,nextAt,enabled) VALUES(?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace=excluded.workspace,source=excluded.source,nextAt=excluded.nextAt,enabled=excluded.enabled', id, definition.id, project, definition.workspace, source, nextAt, Number(definition.enabled));
|
|
117
|
+
return this.one('SELECT * FROM schedules WHERE id=?', id);
|
|
118
|
+
}
|
|
119
|
+
unregister(triggerId, project, now = Date.now()) {
|
|
120
|
+
const id = key([project, triggerId]);
|
|
121
|
+
this.transaction(() => {
|
|
122
|
+
this.run('DELETE FROM schedules WHERE id=?', id);
|
|
123
|
+
this.run('DELETE FROM sources WHERE id=?', id);
|
|
124
|
+
this.run("UPDATE jobs SET state='cancelled',reason='uninstalled',endedAt=? WHERE scheduleId=? AND state='pending'", now, id);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Reloaded definitions drive enable/disable and source edits; absent definitions stop producing jobs. */
|
|
128
|
+
materialize(resolveDefinition, now = Date.now()) {
|
|
129
|
+
return this.transaction(() => {
|
|
130
|
+
const created = [];
|
|
131
|
+
for (const saved of this.schedules()) {
|
|
132
|
+
const definition = resolveDefinition(saved);
|
|
133
|
+
if (!definition || !['calendar', 'interval', 'poll'].includes(definition.source.kind)) continue;
|
|
134
|
+
const source = JSON.stringify(definition.source);
|
|
135
|
+
if (saved.source !== source || saved.workspace !== definition.workspace || Boolean(saved.enabled) !== definition.enabled) {
|
|
136
|
+
this.run('UPDATE schedules SET source=?,workspace=?,enabled=?,nextAt=? WHERE id=?', source, definition.workspace, Number(definition.enabled), nextFiring(definition.source, now), saved.id);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!definition.enabled) continue;
|
|
140
|
+
const firing = dueFiring(definition.source, saved.nextAt, now);
|
|
141
|
+
if (!firing) continue;
|
|
142
|
+
if (!firing.skipped) created.push(this.create({
|
|
143
|
+
id: `cron-${key([saved.id, source, firing.scheduledAt])}`, kind: 'cron', scheduleId: saved.id,
|
|
144
|
+
triggerId: saved.triggerId, project: saved.project, workspace: saved.workspace,
|
|
145
|
+
dueAt: firing.scheduledAt, now, payload: { source: definition.source.kind, fields: { scheduledAt: new Date(firing.scheduledAt).toISOString() } },
|
|
146
|
+
}));
|
|
147
|
+
this.run('UPDATE schedules SET nextAt=? WHERE id=?', firing.nextAt, saved.id);
|
|
148
|
+
}
|
|
149
|
+
return created;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function formatJob(job) {
|
|
155
|
+
return `${job.id} ${job.triggerId} ${job.state}${job.reason ? ` (${job.reason})` : ''} · due ${new Date(job.dueAt).toISOString()}${job.runId ? ` · run ${job.runId}` : ''}${job.sessionId ? ` · session ${job.sessionId}` : ''}`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A permanent inode and a kernel lock serialize CLI processes. The Host inherits
|
|
2
|
+
// the descriptor so a killed parent cannot leave a still-running session unowned.
|
|
3
|
+
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock';
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
|
+
|
|
8
|
+
export async function acquireTriggerLease(home, id, { wait = false, delay = sleep } = {}) {
|
|
9
|
+
const directory = join(home, 'triggers', 'locks');
|
|
10
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
11
|
+
const fd = openSync(join(directory, `${id}.guard`), 'a', 0o600);
|
|
12
|
+
try {
|
|
13
|
+
for (;;) {
|
|
14
|
+
try { await tryLockExclusive(fd); break; }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (!['EAGAIN', 'EWOULDBLOCK'].includes(error.code)) throw error;
|
|
17
|
+
if (!wait) { closeSync(fd); return undefined; }
|
|
18
|
+
await delay(100);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
let released = false;
|
|
22
|
+
return { fd, release() { if (!released) { released = true; closeSync(fd); } } };
|
|
23
|
+
} catch (error) { closeSync(fd); throw error; }
|
|
24
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Agent-facing management uses the same definitions, jobs and scheduler as the CLI.
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, renameSync, rmSync, writeFileSync, linkSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { stringify } from 'yaml';
|
|
7
|
+
import { loadTriggerDefinitions, normalizeTrigger } from './config.mjs';
|
|
8
|
+
import { JobStore } from './jobs.mjs';
|
|
9
|
+
import { normalizeEvent } from './spool.mjs';
|
|
10
|
+
import { dueTime } from './schedule.mjs';
|
|
11
|
+
import { acquireTriggerLease } from './lease.mjs';
|
|
12
|
+
import { schedulerService } from './scheduler-service.mjs';
|
|
13
|
+
|
|
14
|
+
const stable = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
15
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item);
|
|
16
|
+
const required = (value, label) => { if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} is required`); return value; };
|
|
17
|
+
const clean = definition => Object.fromEntries(Object.entries(definition).filter(([key]) => !['origin', 'path', 'overrides'].includes(key)));
|
|
18
|
+
|
|
19
|
+
/** No shell, inherited input or unbounded launchctl wait. */
|
|
20
|
+
function launchctl(args, { ignoreFailure = false } = {}) {
|
|
21
|
+
return new Promise((resolveRun, reject) => {
|
|
22
|
+
const child = spawn('/bin/launchctl', args, { stdio: 'ignore' });
|
|
23
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
|
|
24
|
+
child.once('error', error => { clearTimeout(timer); if (ignoreFailure) resolveRun(1); else reject(error); });
|
|
25
|
+
child.once('exit', code => { clearTimeout(timer); if (code === 0 || ignoreFailure) resolveRun(code ?? 1); else reject(new Error(`launchctl failed (${code})`)); });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class TriggerManagement {
|
|
30
|
+
constructor({ home, dscodePath, platform = process.platform, now = Date.now, service = schedulerService, runLaunchctl = launchctl }) {
|
|
31
|
+
Object.assign(this, { home, dscodePath, platform, now, service, runLaunchctl });
|
|
32
|
+
}
|
|
33
|
+
workspace(agent) {
|
|
34
|
+
const cwd = agent?.session?.header?.cwd;
|
|
35
|
+
if (!cwd || !isAbsolute(cwd)) throw new Error('A session bound to a workspace is required');
|
|
36
|
+
return resolve(cwd);
|
|
37
|
+
}
|
|
38
|
+
definitions(project) { return loadTriggerDefinitions({ home: this.home, workspace: project }); }
|
|
39
|
+
definition(project, id) {
|
|
40
|
+
required(id, 'trigger_id');
|
|
41
|
+
const definition = this.definitions(project).definitions.find(item => item.id === id);
|
|
42
|
+
if (!definition) throw new Error(`No trigger "${id}"`);
|
|
43
|
+
if (resolve(definition.workspace) !== project) throw new Error('The trigger belongs to another workspace');
|
|
44
|
+
return definition;
|
|
45
|
+
}
|
|
46
|
+
runnable(definition) {
|
|
47
|
+
if (!['read-only', 'workspace-write'].includes(definition.permission) || definition.preset !== 'dscode') {
|
|
48
|
+
throw new Error('Agent-managed scheduling requires the dscode preset and read-only or workspace-write permission');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
withStore(fn) {
|
|
52
|
+
const store = new JobStore(this.home);
|
|
53
|
+
try { return fn(store); } finally { store.close(); }
|
|
54
|
+
}
|
|
55
|
+
async status() {
|
|
56
|
+
const lease = await acquireTriggerLease(this.home, '_scheduler');
|
|
57
|
+
const running = !lease;
|
|
58
|
+
lease?.release();
|
|
59
|
+
return { running, ...(!running ? { next_step: 'Use trigger_scheduler(action="install") on macOS, or run dscode trigger scheduler start under a service manager.' } : {}) };
|
|
60
|
+
}
|
|
61
|
+
write(project, definition, existing) {
|
|
62
|
+
const directory = join(project, '.dsh', 'triggers');
|
|
63
|
+
// Do not turn a project-local tool into an arbitrary-file writer via symlinks.
|
|
64
|
+
for (const path of [join(project, '.dsh'), directory]) {
|
|
65
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error('Trigger configuration directories must not be symlinks');
|
|
66
|
+
}
|
|
67
|
+
mkdirSync(directory, { recursive: true });
|
|
68
|
+
const path = existing?.path ?? join(directory, `${definition.id}.yml`);
|
|
69
|
+
if (relative(directory, dirname(path)) !== '' || (existsSync(path) && lstatSync(path).isSymbolicLink())) throw new Error('Only regular project-local definitions can be changed');
|
|
70
|
+
const body = path.endsWith('.json') ? JSON.stringify(clean(definition), null, 2) + '\n' : stringify(clean(definition));
|
|
71
|
+
const temp = join(directory, `.${randomUUID()}.tmp`);
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(temp, body, { flag: 'wx', mode: 0o600 });
|
|
74
|
+
if (existing) renameSync(temp, path);
|
|
75
|
+
else linkSync(temp, path); // Never overwrite a concurrently created definition.
|
|
76
|
+
} finally { rmSync(temp, { force: true }); }
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
async manage(args, agent) {
|
|
80
|
+
const project = this.workspace(agent);
|
|
81
|
+
const { action, trigger_id: id } = args;
|
|
82
|
+
if (action === 'list') {
|
|
83
|
+
const found = this.definitions(project);
|
|
84
|
+
return { definitions: found.definitions.filter(d => resolve(d.workspace) === project), problems: found.problems, schedules: this.withStore(s => s.schedules().filter(row => row.project === project)), sources: this.withStore(s => s.sources().filter(row => row.project === project)), scheduler: await this.status() };
|
|
85
|
+
}
|
|
86
|
+
if (action === 'get') return { definition: this.definition(project, id) };
|
|
87
|
+
if (!['create', 'update', 'enable', 'disable', 'register', 'unregister'].includes(action)) throw new Error('Unknown trigger action');
|
|
88
|
+
let definition;
|
|
89
|
+
if (action === 'create' || action === 'update') {
|
|
90
|
+
required(id, 'trigger_id');
|
|
91
|
+
const found = this.definitions(project);
|
|
92
|
+
const existing = found.definitions.find(d => d.id === id);
|
|
93
|
+
if (action === 'create' && existing) throw new Error('Trigger already exists; use update');
|
|
94
|
+
if (action === 'update' && (!existing || existing.origin !== 'project')) throw new Error('Only existing project-local definitions can be updated');
|
|
95
|
+
if (existing && resolve(existing.workspace) !== project) throw new Error('The trigger belongs to another workspace');
|
|
96
|
+
// A broken JSON/YAML file must be repaired explicitly, never shadowed by a new extension.
|
|
97
|
+
if (found.problems.some(p => ['yml', 'yaml', 'json'].some(ext => p.path === join(project, '.dsh', 'triggers', `${id}.${ext}`)))) throw new Error('Repair the unreadable definition before editing it');
|
|
98
|
+
const patch = args.definition;
|
|
99
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('definition is required for create/update');
|
|
100
|
+
const allowed = ['prompt', 'source', 'goal', 'limits', 'session', 'enabled', 'permission', 'model', 'effort'];
|
|
101
|
+
if (Object.keys(patch).some(key => !allowed.includes(key))) throw new Error(`definition accepts only ${allowed.join(', ')}`);
|
|
102
|
+
const base = existing ? clean(existing) : {};
|
|
103
|
+
if (patch.session) delete base.overlap;
|
|
104
|
+
definition = normalizeTrigger({ ...base, ...patch, id, workspace: project, preset: 'dscode' }, { origin: 'project' });
|
|
105
|
+
if (!['external', 'calendar', 'interval', 'script'].includes(definition.source.kind)) throw new Error('Agent-managed sources must be external, calendar, interval or script');
|
|
106
|
+
this.runnable(definition);
|
|
107
|
+
definition.path = this.write(project, definition, existing);
|
|
108
|
+
// Creating/editing a recurring source also registers it; external has no cadence.
|
|
109
|
+
this.withStore(s => definition.source.kind === 'external' ? s.unregister(id, project, this.now()) : s.register(definition, project, this.now()));
|
|
110
|
+
} else {
|
|
111
|
+
definition = this.definition(project, id);
|
|
112
|
+
if (action === 'register') {
|
|
113
|
+
this.runnable(definition);
|
|
114
|
+
this.withStore(s => s.register(definition, project, this.now()));
|
|
115
|
+
} else if (action === 'unregister') this.withStore(s => s.unregister(id, project, this.now()));
|
|
116
|
+
else {
|
|
117
|
+
if (definition.origin !== 'project') throw new Error('Only project-local definitions can be enabled or disabled');
|
|
118
|
+
definition.enabled = action === 'enable';
|
|
119
|
+
if (definition.enabled) this.runnable(definition);
|
|
120
|
+
this.write(project, definition, definition);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { definition, action, scheduler: await this.status() };
|
|
124
|
+
}
|
|
125
|
+
async source(args, agent) {
|
|
126
|
+
const project = this.workspace(agent);
|
|
127
|
+
const definition = this.definition(project, args.trigger_id);
|
|
128
|
+
if (definition.source.kind !== 'script') throw new Error('source management requires a script source');
|
|
129
|
+
const source = this.withStore(s => {
|
|
130
|
+
if (['status', 'logs'].includes(args.action)) return s.source(definition.id, project);
|
|
131
|
+
this.runnable(definition);
|
|
132
|
+
return s.controlSource(definition, project, args.action);
|
|
133
|
+
});
|
|
134
|
+
if (args.action === 'logs') return { log: source?.log ?? '' };
|
|
135
|
+
return { source: source ?? { status: 'unregistered' }, scheduler: await this.status() };
|
|
136
|
+
}
|
|
137
|
+
async emit(args, agent) {
|
|
138
|
+
const project = this.workspace(agent);
|
|
139
|
+
const definition = this.definition(project, args.trigger_id);
|
|
140
|
+
this.runnable(definition);
|
|
141
|
+
const job = this.withStore(s => s.acceptEvent({ definition, project, payload: args.event ?? {}, eventId: args.eventId, now: this.now() }));
|
|
142
|
+
return { job, scheduler: await this.status() };
|
|
143
|
+
}
|
|
144
|
+
async jobs(args, agent) {
|
|
145
|
+
const project = this.workspace(agent);
|
|
146
|
+
const now = this.now();
|
|
147
|
+
if (args.action === 'list') {
|
|
148
|
+
const limit = args.limit ?? 50;
|
|
149
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('limit must be 1..100');
|
|
150
|
+
return { jobs: this.withStore(s => s.list(args.trigger_id).filter(j => j.project === project && j.workspace === project).slice(0, limit)), scheduler: await this.status() };
|
|
151
|
+
}
|
|
152
|
+
if (args.action === 'cancel') {
|
|
153
|
+
required(args.job_id, 'job_id');
|
|
154
|
+
return this.withStore(s => {
|
|
155
|
+
const job = s.get(args.job_id);
|
|
156
|
+
if (!job || job.project !== project || job.workspace !== project) throw new Error('No job in this workspace');
|
|
157
|
+
if (job.state !== 'cancelled') s.cancel(job.id, now);
|
|
158
|
+
return { job: s.get(job.id) };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (args.action !== 'schedule') throw new Error('Unknown job action');
|
|
162
|
+
const definition = this.definition(project, args.trigger_id);
|
|
163
|
+
this.runnable(definition);
|
|
164
|
+
const requestKey = required(args.idempotency_key, 'idempotency_key');
|
|
165
|
+
if (requestKey.length > 200) throw new Error('idempotency_key must be at most 200 characters');
|
|
166
|
+
const payload = args.event ?? {};
|
|
167
|
+
normalizeEvent(definition.id, payload, { eventId: 'validation', now });
|
|
168
|
+
// Lookup a retry before future-time validation: its original due time may have passed.
|
|
169
|
+
const request = stable({ triggerId: definition.id, after: args.after, at: args.at, payload });
|
|
170
|
+
const job = this.withStore(s => s.createOnce({ requestKey, request, triggerId: definition.id, project, workspace: project, payload, dueAt: () => dueTime(args, now), now }));
|
|
171
|
+
return { job, scheduler: await this.status() };
|
|
172
|
+
}
|
|
173
|
+
async scheduler(args) {
|
|
174
|
+
if (args.action === 'status') return this.status();
|
|
175
|
+
if (args.action !== 'install') throw new Error('Scheduler action must be status or install');
|
|
176
|
+
if (!this.dscodePath || !isAbsolute(this.dscodePath) || !existsSync(this.dscodePath)) throw new Error('Restart DSCODE to provide its launcher path, or install the scheduler through the CLI');
|
|
177
|
+
const messages = [];
|
|
178
|
+
await this.service('install', { home: this.home, dscodePath: this.dscodePath, platform: this.platform, launchctl: this.runLaunchctl, out: text => messages.push(text) });
|
|
179
|
+
return { messages, scheduler: await this.status() };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -75,6 +75,7 @@ export function writeRunSpec(path, spec) {
|
|
|
75
75
|
}
|
|
76
76
|
if (!isPlainObject(spec.goal) || typeof spec.goal.objective !== 'string' || spec.goal.objective.trim() === '') fail('a run spec needs goal.objective');
|
|
77
77
|
if (!Number.isSafeInteger(spec.goal.maxRounds) || spec.goal.maxRounds <= 0) fail('a run spec needs a positive goal.maxRounds');
|
|
78
|
+
validateSessionMode(spec);
|
|
78
79
|
return writeJson(path, spec);
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -84,6 +85,7 @@ export function readRunSpec(path) {
|
|
|
84
85
|
for (const field of ['triggerId', 'runId', 'workspace', 'prompt', 'goal']) {
|
|
85
86
|
if (spec[field] === undefined) fail(`the run spec is missing ${field}`);
|
|
86
87
|
}
|
|
88
|
+
validateSessionMode(spec);
|
|
87
89
|
return spec;
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -107,3 +109,9 @@ export function readRunResult(path) {
|
|
|
107
109
|
return undefined;
|
|
108
110
|
}
|
|
109
111
|
}
|
|
112
|
+
|
|
113
|
+
function validateSessionMode(spec) {
|
|
114
|
+
if (spec.session !== undefined && (!isPlainObject(spec.session) || !['new', 'persistent'].includes(spec.session.mode) || Object.keys(spec.session).some(key => key !== 'mode'))) {
|
|
115
|
+
fail('a run spec session must contain only mode: new or persistent');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// A predicate that cannot run must not fire a run: a spawn failure or a timeout
|
|
7
7
|
// is reported as not matched, never as a match.
|
|
8
8
|
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { sandboxCommand, signalGroup } from './source-sandbox.mjs';
|
|
9
11
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
10
12
|
|
|
11
13
|
/** Output cap for the check's own text, so a chatty check cannot flood a record. */
|
|
@@ -17,12 +19,16 @@ export const MAX_CHECK_OUTPUT_CHARS = 2000;
|
|
|
17
19
|
* @param options - `{ cwd, timeoutMs, spawn }`; `spawn` is injectable for tests.
|
|
18
20
|
* @returns `{ matched, code, output }`: `matched` is true only on exit 0.
|
|
19
21
|
*/
|
|
20
|
-
export function evaluateCheck(check, { cwd, timeoutMs = 60000, spawn = nodeSpawn } = {}) {
|
|
22
|
+
export function evaluateCheck(check, { cwd, timeoutMs = 60000, spawn = nodeSpawn, home, permission = 'read-only' } = {}) {
|
|
21
23
|
return new Promise(resolveCheck => {
|
|
22
|
-
let child;
|
|
24
|
+
let child, scratch;
|
|
25
|
+
const cleanup = () => { if (child) signalGroup(child, 'SIGKILL'); if (scratch) rmSync(scratch, { recursive: true, force: true }); };
|
|
23
26
|
try {
|
|
24
|
-
|
|
27
|
+
scratch = mkdtempSync('/tmp/dscode-poll-');
|
|
28
|
+
const launch = sandboxCommand(['/bin/sh', '-c', check], { workspace: cwd, writable: [scratch], home, permission });
|
|
29
|
+
child = spawn(launch.command, launch.args, { cwd, detached: true, env: { ...process.env, TMPDIR: scratch, TMP: scratch, TEMP: scratch }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
30
|
} catch (error) {
|
|
31
|
+
cleanup();
|
|
26
32
|
resolveCheck({ matched: false, code: null, output: `could not start the check: ${error?.message ?? error}` });
|
|
27
33
|
return;
|
|
28
34
|
}
|
|
@@ -36,7 +42,7 @@ export function evaluateCheck(check, { cwd, timeoutMs = 60000, spawn = nodeSpawn
|
|
|
36
42
|
const timer = setTimeout(() => {
|
|
37
43
|
if (settled) return;
|
|
38
44
|
settled = true;
|
|
39
|
-
|
|
45
|
+
cleanup();
|
|
40
46
|
resolveCheck({ matched: false, code: null, output: `${output}\n(the check exceeded ${timeoutMs}ms and was stopped)`.trim().slice(0, MAX_CHECK_OUTPUT_CHARS) });
|
|
41
47
|
}, timeoutMs);
|
|
42
48
|
timer.unref?.();
|
|
@@ -44,12 +50,14 @@ export function evaluateCheck(check, { cwd, timeoutMs = 60000, spawn = nodeSpawn
|
|
|
44
50
|
if (settled) return;
|
|
45
51
|
settled = true;
|
|
46
52
|
clearTimeout(timer);
|
|
53
|
+
cleanup();
|
|
47
54
|
resolveCheck({ matched: code === 0, code, output: output.replace(/\s+/gu, ' ').trim().slice(0, MAX_CHECK_OUTPUT_CHARS) });
|
|
48
55
|
};
|
|
49
56
|
child.once('error', error => {
|
|
50
57
|
if (settled) return;
|
|
51
58
|
settled = true;
|
|
52
59
|
clearTimeout(timer);
|
|
60
|
+
cleanup();
|
|
53
61
|
resolveCheck({ matched: false, code: null, output: `the check failed to start: ${error?.message ?? error}` });
|
|
54
62
|
});
|
|
55
63
|
child.once('exit', code => finish(code));
|
package/plugins/triggers/run.mjs
CHANGED
|
@@ -142,6 +142,7 @@ export function finishTriggerRun(home, handle, result) {
|
|
|
142
142
|
startedAt: handle.startedAt,
|
|
143
143
|
endedAt: result.endedAt ?? Date.now(),
|
|
144
144
|
eventId: result.eventId ?? handle.eventId,
|
|
145
|
+
...(result.jobId ? { jobId: result.jobId } : {}),
|
|
145
146
|
source: result.source ?? null,
|
|
146
147
|
outcome: result.outcome,
|
|
147
148
|
reason: result.reason ?? null,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
2
|
+
|
|
3
|
+
export function validateCalendar(cron, timezone) {
|
|
4
|
+
if (cron.trim().split(/\s+/u).length !== 5) throw new Error('source.cron must have five fields (minute hour day month weekday)');
|
|
5
|
+
if (!/^[\d*/,\-\s]+$/u.test(cron)) throw new Error('source.cron supports numeric fields, *, ranges, lists and steps');
|
|
6
|
+
new Intl.DateTimeFormat('en', { timeZone: timezone }).format(0);
|
|
7
|
+
CronExpressionParser.parse(cron, { tz: timezone }).next();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function nextFiring(source, after) {
|
|
11
|
+
if (source.kind === 'calendar') return CronExpressionParser.parse(source.cron, { tz: source.timezone, currentDate: after }).next().getTime();
|
|
12
|
+
return after + (source.seconds ?? source.everySeconds) * 1000;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Coalesce all overdue occurrences to the latest one, without iterating years. */
|
|
16
|
+
export function dueFiring(source, nextAt, now) {
|
|
17
|
+
if (now < nextAt) return undefined;
|
|
18
|
+
const scheduledAt = source.kind === 'calendar'
|
|
19
|
+
? CronExpressionParser.parse(source.cron, { tz: source.timezone, currentDate: now + 1 }).prev().getTime()
|
|
20
|
+
: nextAt + Math.floor((now - nextAt) / ((source.seconds ?? source.everySeconds) * 1000)) * (source.seconds ?? source.everySeconds) * 1000;
|
|
21
|
+
return { scheduledAt, nextAt: nextFiring(source, now), skipped: source.misfire === 'skip' && now - scheduledAt >= 60000 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function dueTime({ after, at }, now = Date.now()) {
|
|
25
|
+
if ((after === undefined) === (at === undefined)) throw new Error('schedule needs exactly one of --after or --at');
|
|
26
|
+
let due;
|
|
27
|
+
if (after !== undefined) {
|
|
28
|
+
const match = /^(\d+(?:\.\d+)?)(s|m|h|d)$/u.exec(after);
|
|
29
|
+
if (!match) throw new Error('--after expects a duration such as 30s, 10m, 2h or 1d');
|
|
30
|
+
due = now + Number(match[1]) * { s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]];
|
|
31
|
+
} else {
|
|
32
|
+
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/u.exec(at);
|
|
33
|
+
if (!match) throw new Error('--at needs an ISO timestamp with Z or an explicit UTC offset');
|
|
34
|
+
due = Date.parse(at);
|
|
35
|
+
const offset = match[2] === 'Z' ? 0 : (match[2][0] === '-' ? -1 : 1) * (Number(match[2].slice(1, 3)) * 60 + Number(match[2].slice(4))) * 60000;
|
|
36
|
+
if (!Number.isFinite(due) || new Date(due + offset).toISOString().slice(0, 19) !== match[1]) throw new Error('--at is not a valid calendar timestamp');
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isSafeInteger(due) || due <= now || due > 8640000000000000) throw new Error('the scheduled time must be in the future');
|
|
39
|
+
return due;
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const xml = value => String(value).replace(/[<>&"']/gu, char => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[char]));
|
|
7
|
+
export const schedulerLabel = home => `ai.dscode.scheduler.${createHash('sha256').update(home).digest('hex').slice(0, 12)}`;
|
|
8
|
+
export const schedulerPath = (home, directory = join(homedir(), 'Library', 'LaunchAgents')) => join(directory, `${schedulerLabel(home)}.plist`);
|
|
9
|
+
|
|
10
|
+
export function schedulerPlist(home, dscodePath) {
|
|
11
|
+
const args = [process.execPath, dscodePath, 'trigger', 'scheduler', 'start'];
|
|
12
|
+
const log = join(home, 'triggers', 'scheduler.log');
|
|
13
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
14
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
15
|
+
<plist version="1.0"><dict>
|
|
16
|
+
<key>Label</key><string>${xml(schedulerLabel(home))}</string>
|
|
17
|
+
<key>ProgramArguments</key><array>${args.map(arg => `<string>${xml(arg)}</string>`).join('')}</array>
|
|
18
|
+
<key>WorkingDirectory</key><string>${xml(home)}</string>
|
|
19
|
+
<key>EnvironmentVariables</key><dict><key>DSH_HOME</key><string>${xml(home)}</string><key>DSCODE_HOME</key><string>${xml(home)}</string><key>PATH</key><string>${xml(`${dirname(process.execPath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`)}</string></dict>
|
|
20
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
|
|
21
|
+
<key>ThrottleInterval</key><integer>5</integer>
|
|
22
|
+
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
23
|
+
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
24
|
+
</dict></plist>
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function schedulerService(action, { home, dscodePath, platform, launchctl, agentsDirectory, out }) {
|
|
29
|
+
if (platform !== 'darwin' || !launchctl) {
|
|
30
|
+
out(`Manage the scheduler with your service manager: ${JSON.stringify(process.execPath)} ${JSON.stringify(dscodePath)} trigger scheduler start (DSH_HOME=${JSON.stringify(home)}, DSCODE_HOME=${JSON.stringify(home)})`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const path = schedulerPath(home, agentsDirectory);
|
|
34
|
+
const label = `gui/${process.getuid()}/${schedulerLabel(home)}`;
|
|
35
|
+
if (action === 'uninstall') {
|
|
36
|
+
await launchctl(['bootout', label], { ignoreFailure: true });
|
|
37
|
+
rmSync(path, { force: true });
|
|
38
|
+
out(`removed scheduler service ${path}; pending jobs remain on disk`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
mkdirSync(join(home, 'triggers'), { recursive: true, mode: 0o700 });
|
|
43
|
+
writeFileSync(path, schedulerPlist(home, dscodePath), { mode: 0o600 });
|
|
44
|
+
if (await launchctl(['print', label], { ignoreFailure: true }) !== 0) await launchctl(['bootstrap', `gui/${process.getuid()}`, path]);
|
|
45
|
+
out(`scheduler installed: ${path}`);
|
|
46
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { SourceSupervisor } from './sources.mjs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
import { JobStore } from './jobs.mjs';
|
|
7
|
+
import { loadTriggerDefinitions } from './config.mjs';
|
|
8
|
+
import { acquireTriggerLease } from './lease.mjs';
|
|
9
|
+
import { alivePid } from './run.mjs';
|
|
10
|
+
import { readRuns } from './log.mjs';
|
|
11
|
+
|
|
12
|
+
export function launchJobWorker({ home, dscodePath, job }) {
|
|
13
|
+
const directory = join(home, 'triggers', 'jobs');
|
|
14
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
15
|
+
const fd = openSync(join(directory, `${job.id}.log`), 'a', 0o600);
|
|
16
|
+
let child;
|
|
17
|
+
try {
|
|
18
|
+
child = spawn(process.execPath, [dscodePath, 'trigger', 'run-job', job.id], {
|
|
19
|
+
cwd: home, env: { ...process.env, DSH_HOME: home, DSCODE_HOME: home },
|
|
20
|
+
stdio: ['ignore', fd, fd],
|
|
21
|
+
});
|
|
22
|
+
} finally { closeSync(fd); }
|
|
23
|
+
// Workers may finish after the scheduler exits. Their trigger leases and
|
|
24
|
+
// durable claims still exclude a replacement scheduler from double-running.
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
child.once('error', reject);
|
|
27
|
+
child.once('exit', code => resolve(code ?? 130));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One scan; active promises are tracked separately so model latency cannot stop the clock. */
|
|
32
|
+
export async function schedulerTick({ home, store, active, dscodePath, now = Date.now(), spawnWorker = launchJobWorker, report = console.error }) {
|
|
33
|
+
const definitions = new Map();
|
|
34
|
+
const resolve = saved => {
|
|
35
|
+
if (!definitions.has(saved.project)) {
|
|
36
|
+
const found = loadTriggerDefinitions({ home, workspace: saved.project });
|
|
37
|
+
for (const problem of found.problems) report(`${problem.path}: ${problem.message}`);
|
|
38
|
+
definitions.set(saved.project, found.definitions);
|
|
39
|
+
}
|
|
40
|
+
return definitions.get(saved.project).find(d => d.id === saved.triggerId);
|
|
41
|
+
};
|
|
42
|
+
store.materialize(resolve, now);
|
|
43
|
+
for (const job of store.running()) {
|
|
44
|
+
if (alivePid(job.pid)) continue;
|
|
45
|
+
const lease = await acquireTriggerLease(home, job.triggerId);
|
|
46
|
+
if (!lease) continue; // its orphaned Host still owns the descriptor
|
|
47
|
+
try {
|
|
48
|
+
const result = readRuns(home, { triggerId: job.triggerId, limit: 0 }).find(run => run.runId === job.runId);
|
|
49
|
+
store.finish(job.id, result ?? { exitCode: 130, reason: 'interrupted' }, now);
|
|
50
|
+
} finally { lease.release(); }
|
|
51
|
+
}
|
|
52
|
+
const busy = new Set([...active.values()].map(entry => entry.triggerId));
|
|
53
|
+
for (const job of store.pending(now)) {
|
|
54
|
+
if (active.size >= 4) break;
|
|
55
|
+
if (busy.has(job.triggerId)) continue;
|
|
56
|
+
busy.add(job.triggerId);
|
|
57
|
+
const promise = Promise.resolve().then(() => spawnWorker({ home, dscodePath, job })).then(code => {
|
|
58
|
+
if (code && store.get(job.id)?.state === 'pending') store.defer(job.id, 'worker_start_failed', Date.now() + 30000);
|
|
59
|
+
}).catch(error => {
|
|
60
|
+
report(`scheduler ${job.id}: ${error.message}`);
|
|
61
|
+
store.defer(job.id, 'worker_start_failed', Date.now() + 30000);
|
|
62
|
+
}).finally(() => { active.delete(job.id); });
|
|
63
|
+
active.set(job.id, { triggerId: job.triggerId, promise });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runScheduler({ home, dscodePath, once = false, signal, spawnWorker, report = console.error }) {
|
|
68
|
+
const lease = await acquireTriggerLease(home, '_scheduler');
|
|
69
|
+
if (!lease) throw new Error('the scheduler is already running for this state directory');
|
|
70
|
+
let store, sources;
|
|
71
|
+
const active = new Map();
|
|
72
|
+
try {
|
|
73
|
+
store = new JobStore(home);
|
|
74
|
+
sources = new SourceSupervisor({ home, store, report });
|
|
75
|
+
do {
|
|
76
|
+
if (!once) sources.tick();
|
|
77
|
+
await schedulerTick({ home, store, active, dscodePath, spawnWorker, report });
|
|
78
|
+
if (once) break;
|
|
79
|
+
await sleep(1000, undefined, { signal }).catch(error => { if (error.name !== 'AbortError') throw error; });
|
|
80
|
+
} while (!signal?.aborted);
|
|
81
|
+
await sources.close();
|
|
82
|
+
await Promise.allSettled([...active.values()].map(entry => entry.promise));
|
|
83
|
+
return 0;
|
|
84
|
+
} finally {
|
|
85
|
+
await sources?.close();
|
|
86
|
+
await Promise.allSettled([...active.values()].map(entry => entry.promise));
|
|
87
|
+
store?.close();
|
|
88
|
+
lease.release();
|
|
89
|
+
}
|
|
90
|
+
}
|