paneful 0.9.18 → 0.9.20
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/README.md +15 -0
- package/dist/server/index.js +31 -0
- package/dist/server/pty-manager.js +54 -2
- package/dist/server/schedule-store.js +99 -0
- package/dist/server/scheduler.js +189 -0
- package/dist/server/ws-handler.js +303 -6
- package/dist/web/assets/index-BCSI40O7.css +32 -0
- package/dist/web/assets/index-eFW4nICJ.js +87 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-0oTUoIKa.js +0 -88
- package/dist/web/assets/index-C-posrJu.css +0 -32
package/README.md
CHANGED
|
@@ -67,6 +67,21 @@ Press `Cmd+P` to open the command palette. Quickly switch projects, launch favou
|
|
|
67
67
|
|
|
68
68
|
The sidebar shows the current Git branch next to each project's working directory as a small pill badge. Updates automatically every 10 seconds. Non-git directories show no badge.
|
|
69
69
|
|
|
70
|
+
### Scheduled Jobs (beta)
|
|
71
|
+
|
|
72
|
+
Run any shell command on a recurring schedule. Open the **Schedules** section in the sidebar (below Favourites) and click **+** to create one. Pick a name, a working directory via the Finder picker, the command to run, and a schedule — every N minutes/hours/days, daily at HH:MM, weekly on chosen days, or a raw cron expression. The next-fire time is shown live as you build the schedule.
|
|
73
|
+
|
|
74
|
+
Each fire spawns a fresh server-side terminal in your `$SHELL` (login + interactive, so your full PATH is loaded) and runs the command — schedules are completely separate from projects so they don't pollute your layouts. The command runs, then drops into an interactive shell, so the terminal **never dies on its own** — you decide when to close it.
|
|
75
|
+
|
|
76
|
+
Every run is captured to disk so the history is inspectable later. Click any past run from the schedule's history dialog to open an interactive viewer:
|
|
77
|
+
|
|
78
|
+
- **Active runs** — live streaming output, full keystroke forwarding (answer Claude prompts, type follow-up commands, etc.).
|
|
79
|
+
- **Closed runs** — read-only replay of the captured log via xterm so colors and formatting are preserved.
|
|
80
|
+
- **Pause / Resume** — SIGSTOP / SIGCONT the running process. The shell freezes exactly where it was and continues from that exact state when you come back.
|
|
81
|
+
- **Terminate** — kills the run when you're done with it.
|
|
82
|
+
|
|
83
|
+
A common pattern: schedule a Claude CLI command (`claude --dangerously-skip-permissions "..."`) to run at 8am on weekdays. When it fires you get a toast; click it to drop into the live terminal, answer any prompts, and let it work.
|
|
84
|
+
|
|
70
85
|
### Source Control
|
|
71
86
|
|
|
72
87
|
Press `Cmd+Shift+G` (or click the branch icon in the toolbar) to open the source control panel — a resizable right-side panel that shows the active project's working changes. Powered by `git status` + `fs.watch`, so updates are instant. Includes:
|
package/dist/server/index.js
CHANGED
|
@@ -268,6 +268,37 @@ async function startServer(devMode, port) {
|
|
|
268
268
|
res.json({ valid: false });
|
|
269
269
|
}
|
|
270
270
|
});
|
|
271
|
+
// macOS-only Finder folder picker via osascript. Returns the chosen POSIX path or null.
|
|
272
|
+
app.post('/api/pick-folder', async (req, res) => {
|
|
273
|
+
if (process.platform !== 'darwin') {
|
|
274
|
+
res.json({ path: null, error: 'folder picker is macOS-only' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const { default: defaultLocation } = req.body ?? {};
|
|
278
|
+
const seed = (typeof defaultLocation === 'string' && defaultLocation)
|
|
279
|
+
? defaultLocation.replace(/^~/, os.homedir())
|
|
280
|
+
: os.homedir();
|
|
281
|
+
const { execFile } = await import('node:child_process');
|
|
282
|
+
const script = `
|
|
283
|
+
try
|
|
284
|
+
tell application "System Events"
|
|
285
|
+
activate
|
|
286
|
+
set chosen to choose folder with prompt "Select a folder" default location POSIX file "${seed.replace(/"/g, '\\"')}"
|
|
287
|
+
end tell
|
|
288
|
+
return POSIX path of chosen
|
|
289
|
+
on error number -128
|
|
290
|
+
return ""
|
|
291
|
+
end try
|
|
292
|
+
`;
|
|
293
|
+
execFile('osascript', ['-e', script], { timeout: 120_000 }, (err, stdout) => {
|
|
294
|
+
if (err) {
|
|
295
|
+
res.json({ path: null });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const trimmed = stdout.trim().replace(/\/$/, '');
|
|
299
|
+
res.json({ path: trimmed || null });
|
|
300
|
+
});
|
|
301
|
+
});
|
|
271
302
|
// Resolve a dropped file's full path via tiered search (stat → find → Spotlight)
|
|
272
303
|
const resolvePathCache = new Map();
|
|
273
304
|
const RESOLVE_CACHE_TTL = 30_000;
|
|
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
export class PtyManager {
|
|
5
5
|
sessions = new Map();
|
|
6
|
-
spawn(terminalId, projectId, cwd, onOutput, onExit) {
|
|
6
|
+
spawn(terminalId, projectId, cwd, onOutput, onExit, command) {
|
|
7
7
|
const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
|
|
8
8
|
// Filter out undefined values from process.env before spreading
|
|
9
9
|
const env = {};
|
|
@@ -14,7 +14,22 @@ export class PtyManager {
|
|
|
14
14
|
env.TERM = 'xterm-256color';
|
|
15
15
|
env.LANG = 'en_US.UTF-8';
|
|
16
16
|
env.LC_ALL = 'en_US.UTF-8';
|
|
17
|
-
|
|
17
|
+
// Command mode: run the command in the user's own login+interactive shell,
|
|
18
|
+
// then drop back into an interactive shell so the PTY stays alive (matches
|
|
19
|
+
// Terminal.app behavior). We use `-l -i` so both profile AND rc files
|
|
20
|
+
// (e.g. ~/.zshrc) are sourced — that's where user-installed binaries
|
|
21
|
+
// like `claude`, `nvm`-managed `node`, etc. land on PATH.
|
|
22
|
+
//
|
|
23
|
+
// Passing the command via $0 + eval keeps the user's command opaque to the
|
|
24
|
+
// wrapper script's quoting.
|
|
25
|
+
const wrapper = `eval "$0"; printf '\\r\\n\\033[90m[paneful: command finished — type exit to close this run]\\033[0m\\r\\n'; exec "$1" -i -l`;
|
|
26
|
+
const isCommandMode = !!command;
|
|
27
|
+
// bash and zsh both accept -l -i -c SCRIPT ARG ARG. Fish doesn't, so fall back to bash.
|
|
28
|
+
const wrapperShell = /\/(bash|zsh)$/.test(shell) ? shell : '/bin/bash';
|
|
29
|
+
const args = isCommandMode
|
|
30
|
+
? ['-l', '-i', '-c', wrapper, command, shell]
|
|
31
|
+
: ['--login'];
|
|
32
|
+
const proc = pty.spawn(isCommandMode ? wrapperShell : shell, args, {
|
|
18
33
|
name: 'xterm-256color',
|
|
19
34
|
cols: 80,
|
|
20
35
|
rows: 24,
|
|
@@ -70,6 +85,26 @@ export class PtyManager {
|
|
|
70
85
|
}
|
|
71
86
|
this.sessions.clear();
|
|
72
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Pauses the PTY by sending SIGSTOP to its process group. The shell and its
|
|
90
|
+
* descendants freeze in place — no CPU, no I/O — and their in-memory state
|
|
91
|
+
* is preserved until `cont()` is called.
|
|
92
|
+
*/
|
|
93
|
+
pause(terminalId) {
|
|
94
|
+
const managed = this.sessions.get(terminalId);
|
|
95
|
+
if (!managed)
|
|
96
|
+
return false;
|
|
97
|
+
return signalPty(managed.process.pid, 'SIGSTOP');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Continues a paused PTY (SIGCONT). The process picks up exactly where it was.
|
|
101
|
+
*/
|
|
102
|
+
cont(terminalId) {
|
|
103
|
+
const managed = this.sessions.get(terminalId);
|
|
104
|
+
if (!managed)
|
|
105
|
+
return false;
|
|
106
|
+
return signalPty(managed.process.pid, 'SIGCONT');
|
|
107
|
+
}
|
|
73
108
|
terminalExists(terminalId) {
|
|
74
109
|
return this.sessions.has(terminalId);
|
|
75
110
|
}
|
|
@@ -130,3 +165,20 @@ export class PtyManager {
|
|
|
130
165
|
const RUNTIME_PROCESSES = new Set(['node', 'python', 'python3']);
|
|
131
166
|
// Match agent binary names at the end of a path or as a standalone token
|
|
132
167
|
const AGENT_CMD_PATTERN = /(?:^|\/)(codex|claude|aider)(?:\s|$)/;
|
|
168
|
+
// Signal the PTY's whole process group (negative pid) so children pause/continue with it.
|
|
169
|
+
// Falls back to single-process if the group signal fails (unlikely on macOS).
|
|
170
|
+
function signalPty(pid, signal) {
|
|
171
|
+
try {
|
|
172
|
+
process.kill(-pid, signal);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, signal);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const RUN_HISTORY_LIMIT = 200;
|
|
4
|
+
export class ScheduleStore {
|
|
5
|
+
jobs = new Map();
|
|
6
|
+
runs = [];
|
|
7
|
+
filePath;
|
|
8
|
+
constructor(dataDir, getProjectCwd) {
|
|
9
|
+
this.filePath = path.join(dataDir, 'schedules.json');
|
|
10
|
+
if (fs.existsSync(this.filePath)) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
|
|
13
|
+
let mutated = false;
|
|
14
|
+
for (const job of raw.jobs ?? []) {
|
|
15
|
+
// Migration: older schedules used projectId, now we store cwd directly
|
|
16
|
+
if (!job.cwd && job.projectId && getProjectCwd) {
|
|
17
|
+
const cwd = getProjectCwd(job.projectId);
|
|
18
|
+
if (cwd) {
|
|
19
|
+
job.cwd = cwd;
|
|
20
|
+
mutated = true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
delete job.projectId;
|
|
24
|
+
if (job.cwd)
|
|
25
|
+
this.jobs.set(job.id, job);
|
|
26
|
+
}
|
|
27
|
+
this.runs = (raw.runs ?? []).slice(-RUN_HISTORY_LIMIT);
|
|
28
|
+
if (mutated)
|
|
29
|
+
this.persist();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// corrupt file, start fresh
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
listJobs() {
|
|
37
|
+
return Array.from(this.jobs.values()).sort((a, b) => a.createdAt - b.createdAt);
|
|
38
|
+
}
|
|
39
|
+
getJob(id) {
|
|
40
|
+
return this.jobs.get(id);
|
|
41
|
+
}
|
|
42
|
+
createJob(job) {
|
|
43
|
+
this.jobs.set(job.id, job);
|
|
44
|
+
this.persist();
|
|
45
|
+
}
|
|
46
|
+
updateJob(job) {
|
|
47
|
+
if (!this.jobs.has(job.id))
|
|
48
|
+
return;
|
|
49
|
+
this.jobs.set(job.id, job);
|
|
50
|
+
this.persist();
|
|
51
|
+
}
|
|
52
|
+
deleteJob(id) {
|
|
53
|
+
this.jobs.delete(id);
|
|
54
|
+
// Drop runs for the deleted job too
|
|
55
|
+
this.runs = this.runs.filter((r) => r.jobId !== id);
|
|
56
|
+
this.persist();
|
|
57
|
+
}
|
|
58
|
+
listRuns(jobId) {
|
|
59
|
+
return jobId ? this.runs.filter((r) => r.jobId === jobId) : this.runs.slice();
|
|
60
|
+
}
|
|
61
|
+
addRun(run) {
|
|
62
|
+
this.runs.push(run);
|
|
63
|
+
if (this.runs.length > RUN_HISTORY_LIMIT) {
|
|
64
|
+
this.runs.splice(0, this.runs.length - RUN_HISTORY_LIMIT);
|
|
65
|
+
}
|
|
66
|
+
this.persist();
|
|
67
|
+
}
|
|
68
|
+
updateRun(runId, patch) {
|
|
69
|
+
const idx = this.runs.findIndex((r) => r.id === runId);
|
|
70
|
+
if (idx < 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
this.runs[idx] = { ...this.runs[idx], ...patch };
|
|
73
|
+
this.persist();
|
|
74
|
+
return this.runs[idx];
|
|
75
|
+
}
|
|
76
|
+
removeRun(runId) {
|
|
77
|
+
this.runs = this.runs.filter((r) => r.id !== runId);
|
|
78
|
+
this.persist();
|
|
79
|
+
}
|
|
80
|
+
hasEnabledJobs() {
|
|
81
|
+
for (const job of this.jobs.values()) {
|
|
82
|
+
if (job.enabled)
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
persist() {
|
|
88
|
+
try {
|
|
89
|
+
const dir = path.dirname(this.filePath);
|
|
90
|
+
if (!fs.existsSync(dir))
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
const data = { jobs: this.listJobs(), runs: this.runs };
|
|
93
|
+
fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
console.error('Failed to persist schedules');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal cron matcher. Supports 5-field standard cron:
|
|
3
|
+
* minute hour day-of-month month day-of-week
|
|
4
|
+
*
|
|
5
|
+
* Field syntax:
|
|
6
|
+
* * (any)
|
|
7
|
+
* N (literal)
|
|
8
|
+
* N,M,O (list)
|
|
9
|
+
* N-M (range)
|
|
10
|
+
* *\/N (step)
|
|
11
|
+
* N-M/K (stepped range)
|
|
12
|
+
*
|
|
13
|
+
* Day-of-week: 0-6 (0=Sunday).
|
|
14
|
+
*/
|
|
15
|
+
const RANGES = [
|
|
16
|
+
[0, 59], // minute
|
|
17
|
+
[0, 23], // hour
|
|
18
|
+
[1, 31], // day of month
|
|
19
|
+
[1, 12], // month
|
|
20
|
+
[0, 6], // day of week
|
|
21
|
+
];
|
|
22
|
+
function parseField(field, min, max) {
|
|
23
|
+
if (field === '*')
|
|
24
|
+
return 'any';
|
|
25
|
+
const values = new Set();
|
|
26
|
+
for (const part of field.split(',')) {
|
|
27
|
+
let stepStr;
|
|
28
|
+
let rangeStr = part;
|
|
29
|
+
const slashIdx = part.indexOf('/');
|
|
30
|
+
if (slashIdx >= 0) {
|
|
31
|
+
rangeStr = part.slice(0, slashIdx);
|
|
32
|
+
stepStr = part.slice(slashIdx + 1);
|
|
33
|
+
}
|
|
34
|
+
const step = stepStr ? parseInt(stepStr, 10) : 1;
|
|
35
|
+
if (!Number.isFinite(step) || step <= 0)
|
|
36
|
+
continue;
|
|
37
|
+
let lo;
|
|
38
|
+
let hi;
|
|
39
|
+
if (rangeStr === '*') {
|
|
40
|
+
lo = min;
|
|
41
|
+
hi = max;
|
|
42
|
+
}
|
|
43
|
+
else if (rangeStr.includes('-')) {
|
|
44
|
+
const [a, b] = rangeStr.split('-');
|
|
45
|
+
lo = parseInt(a, 10);
|
|
46
|
+
hi = parseInt(b, 10);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lo = parseInt(rangeStr, 10);
|
|
50
|
+
hi = lo;
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi))
|
|
53
|
+
continue;
|
|
54
|
+
for (let v = lo; v <= hi; v += step) {
|
|
55
|
+
if (v >= min && v <= max)
|
|
56
|
+
values.add(v);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return values;
|
|
60
|
+
}
|
|
61
|
+
export function parseCron(expr) {
|
|
62
|
+
// Aliases
|
|
63
|
+
let trimmed = expr.trim();
|
|
64
|
+
switch (trimmed) {
|
|
65
|
+
case '@yearly':
|
|
66
|
+
case '@annually':
|
|
67
|
+
trimmed = '0 0 1 1 *';
|
|
68
|
+
break;
|
|
69
|
+
case '@monthly':
|
|
70
|
+
trimmed = '0 0 1 * *';
|
|
71
|
+
break;
|
|
72
|
+
case '@weekly':
|
|
73
|
+
trimmed = '0 0 * * 0';
|
|
74
|
+
break;
|
|
75
|
+
case '@daily':
|
|
76
|
+
case '@midnight':
|
|
77
|
+
trimmed = '0 0 * * *';
|
|
78
|
+
break;
|
|
79
|
+
case '@hourly':
|
|
80
|
+
trimmed = '0 * * * *';
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const fields = trimmed.split(/\s+/);
|
|
84
|
+
if (fields.length !== 5)
|
|
85
|
+
return null;
|
|
86
|
+
const parsed = {
|
|
87
|
+
minute: parseField(fields[0], RANGES[0][0], RANGES[0][1]),
|
|
88
|
+
hour: parseField(fields[1], RANGES[1][0], RANGES[1][1]),
|
|
89
|
+
dom: parseField(fields[2], RANGES[2][0], RANGES[2][1]),
|
|
90
|
+
month: parseField(fields[3], RANGES[3][0], RANGES[3][1]),
|
|
91
|
+
dow: parseField(fields[4], RANGES[4][0], RANGES[4][1]),
|
|
92
|
+
};
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
function matches(set, val) {
|
|
96
|
+
return set === 'any' || set.has(val);
|
|
97
|
+
}
|
|
98
|
+
export function cronMatches(expr, date) {
|
|
99
|
+
const parsed = parseCron(expr);
|
|
100
|
+
if (!parsed)
|
|
101
|
+
return false;
|
|
102
|
+
return (matches(parsed.minute, date.getMinutes()) &&
|
|
103
|
+
matches(parsed.hour, date.getHours()) &&
|
|
104
|
+
matches(parsed.dom, date.getDate()) &&
|
|
105
|
+
matches(parsed.month, date.getMonth() + 1) &&
|
|
106
|
+
matches(parsed.dow, date.getDay()));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Compute the next firing time at-or-after `from`, scanning up to
|
|
110
|
+
* `maxMinutes` ahead. Used for "next run in X" UI hints.
|
|
111
|
+
*/
|
|
112
|
+
export function nextRun(expr, from, maxMinutes = 60 * 24 * 7) {
|
|
113
|
+
const parsed = parseCron(expr);
|
|
114
|
+
if (!parsed)
|
|
115
|
+
return null;
|
|
116
|
+
const cursor = new Date(from);
|
|
117
|
+
cursor.setSeconds(0, 0);
|
|
118
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
119
|
+
for (let i = 0; i < maxMinutes; i++) {
|
|
120
|
+
if (matches(parsed.minute, cursor.getMinutes()) &&
|
|
121
|
+
matches(parsed.hour, cursor.getHours()) &&
|
|
122
|
+
matches(parsed.dom, cursor.getDate()) &&
|
|
123
|
+
matches(parsed.month, cursor.getMonth() + 1) &&
|
|
124
|
+
matches(parsed.dow, cursor.getDay())) {
|
|
125
|
+
return cursor;
|
|
126
|
+
}
|
|
127
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
export class Scheduler {
|
|
132
|
+
store;
|
|
133
|
+
onFire;
|
|
134
|
+
timer = null;
|
|
135
|
+
lastTickMinute = null;
|
|
136
|
+
destroyed = false;
|
|
137
|
+
constructor(store, onFire) {
|
|
138
|
+
this.store = store;
|
|
139
|
+
this.onFire = onFire;
|
|
140
|
+
}
|
|
141
|
+
start() {
|
|
142
|
+
if (this.destroyed || this.timer)
|
|
143
|
+
return;
|
|
144
|
+
this.scheduleNextTick();
|
|
145
|
+
}
|
|
146
|
+
stop() {
|
|
147
|
+
if (this.timer) {
|
|
148
|
+
clearTimeout(this.timer);
|
|
149
|
+
this.timer = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
destroy() {
|
|
153
|
+
this.destroyed = true;
|
|
154
|
+
this.stop();
|
|
155
|
+
}
|
|
156
|
+
scheduleNextTick() {
|
|
157
|
+
if (this.destroyed)
|
|
158
|
+
return;
|
|
159
|
+
const now = new Date();
|
|
160
|
+
// Sleep until the start of the next minute (+50ms slack to avoid edge cases)
|
|
161
|
+
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
|
162
|
+
this.timer = setTimeout(() => {
|
|
163
|
+
this.tick();
|
|
164
|
+
this.scheduleNextTick();
|
|
165
|
+
}, msToNextMinute);
|
|
166
|
+
}
|
|
167
|
+
tick() {
|
|
168
|
+
if (this.destroyed)
|
|
169
|
+
return;
|
|
170
|
+
const now = new Date();
|
|
171
|
+
now.setSeconds(0, 0);
|
|
172
|
+
const minuteKey = now.getTime();
|
|
173
|
+
if (minuteKey === this.lastTickMinute)
|
|
174
|
+
return;
|
|
175
|
+
this.lastTickMinute = minuteKey;
|
|
176
|
+
for (const job of this.store.listJobs()) {
|
|
177
|
+
if (!job.enabled)
|
|
178
|
+
continue;
|
|
179
|
+
try {
|
|
180
|
+
if (cronMatches(job.cron, now)) {
|
|
181
|
+
this.onFire(job);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
console.error('schedule check failed for', job.id, e);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|