glad-web 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ const { sequenceForKey } = require('./key-sequences');
2
+
3
+ function sleep(ms) {
4
+ return new Promise(resolve => setTimeout(resolve, ms));
5
+ }
6
+
7
+ class SessionEndedError extends Error {
8
+ constructor() {
9
+ super('Session ended');
10
+ this.code = 'SESSION_ENDED';
11
+ }
12
+ }
13
+
14
+ class JobRunner {
15
+ constructor({ createSession, getJob, updateJob, logger }) {
16
+ this.createSession = createSession;
17
+ this.getJob = getJob;
18
+ this.updateJob = updateJob;
19
+ this.logger = logger;
20
+ this.running = new Set();
21
+ }
22
+
23
+ async run(jobId, options = {}) {
24
+ const job = this.getJob(jobId);
25
+ if (!job) throw new Error('Scheduled task not found');
26
+ if (this.running.has(job.id)) {
27
+ this.updateJob(job.id, {
28
+ lastRunAt: Date.now(),
29
+ lastRunStatus: 'skipped',
30
+ lastRunMessage: 'Previous run is still active'
31
+ });
32
+ return { skipped: true, reason: 'Previous run is still active' };
33
+ }
34
+
35
+ this.running.add(job.id);
36
+ this.updateJob(job.id, {
37
+ running: true,
38
+ lastRunAt: Date.now(),
39
+ lastRunStatus: options.manual ? 'manual-running' : 'running',
40
+ lastRunMessage: ''
41
+ });
42
+
43
+ let session = null;
44
+ try {
45
+ session = this.createSession({
46
+ toolKey: job.target.toolKey,
47
+ workingDirectory: job.target.workingDirectory,
48
+ name: `${job.name}${options.manual ? ' (Test)' : ''}`
49
+ });
50
+
51
+ this.updateJob(job.id, {
52
+ lastSessionId: session.id,
53
+ lastRunMessage: `Started session ${session.id}`
54
+ });
55
+
56
+ const execute = async () => {
57
+ try {
58
+ await sleep(1000);
59
+ await this.executeSteps(job, session);
60
+ this.updateJob(job.id, {
61
+ running: false,
62
+ lastRunStatus: options.manual ? 'manual-success' : 'success',
63
+ lastRunMessage: `Started session ${session.id}`,
64
+ lastSessionId: session.id
65
+ });
66
+ } catch (error) {
67
+ const ended = error && error.code === 'SESSION_ENDED';
68
+ if (!ended) this.logger?.error?.(`Scheduled task failed: ${error.message}`);
69
+ this.updateJob(job.id, {
70
+ running: false,
71
+ lastRunStatus: ended ? 'cancelled' : 'failed',
72
+ lastRunMessage: ended ? 'Session ended' : error.message,
73
+ lastSessionId: session?.id || null
74
+ });
75
+ if (!options.background) throw error;
76
+ } finally {
77
+ this.running.delete(job.id);
78
+ this.updateJob(job.id, { running: false });
79
+ }
80
+ };
81
+
82
+ if (options.background) {
83
+ execute();
84
+ return { success: true, sessionId: session.id };
85
+ }
86
+
87
+ await execute();
88
+ return { success: true, sessionId: session.id };
89
+ } catch (error) {
90
+ if (!session) {
91
+ this.running.delete(job.id);
92
+ this.updateJob(job.id, { running: false });
93
+ }
94
+ this.logger?.error?.(`Scheduled task failed: ${error.message}`);
95
+ this.updateJob(job.id, {
96
+ running: false,
97
+ lastRunStatus: 'failed',
98
+ lastRunMessage: error.message,
99
+ lastSessionId: session?.id || null
100
+ });
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ async executeSteps(job, session) {
106
+ const modifiers = { ctrl: false, alt: false };
107
+
108
+ for (const step of job.steps || []) {
109
+ this.assertSessionRunning(session);
110
+ if (step.type === 'sleep') {
111
+ await this.sleepWhileRunning(session, Math.max(0, Number(step.seconds) || 0) * 1000);
112
+ continue;
113
+ }
114
+
115
+ if (step.type === 'sendText') {
116
+ if (!session.write(String(step.text || ''))) throw new SessionEndedError();
117
+ continue;
118
+ }
119
+
120
+ if (step.type === 'sendKey') {
121
+ if (!session.write(sequenceForKey(step.key, modifiers))) throw new SessionEndedError();
122
+ continue;
123
+ }
124
+
125
+ if (step.type === 'keyDown') {
126
+ const key = String(step.key || '').toLowerCase();
127
+ if (key === 'ctrl' || key === 'alt') modifiers[key] = true;
128
+ continue;
129
+ }
130
+
131
+ if (step.type === 'keyUp') {
132
+ const key = String(step.key || '').toLowerCase();
133
+ if (key === 'ctrl' || key === 'alt') modifiers[key] = false;
134
+ continue;
135
+ }
136
+
137
+ if (step.type === 'stop') break;
138
+
139
+ if (step.type === 'closeSession') {
140
+ session.kill?.();
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ assertSessionRunning(session) {
147
+ if (session && typeof session.isRunning === 'function' && !session.isRunning()) {
148
+ throw new SessionEndedError();
149
+ }
150
+ }
151
+
152
+ async sleepWhileRunning(session, ms) {
153
+ const end = Date.now() + ms;
154
+ while (Date.now() < end) {
155
+ this.assertSessionRunning(session);
156
+ await sleep(Math.min(500, end - Date.now()));
157
+ }
158
+ this.assertSessionRunning(session);
159
+ }
160
+ }
161
+
162
+ module.exports = JobRunner;
@@ -0,0 +1,150 @@
1
+ const Conf = require('conf');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { v4: uuidv4 } = require('uuid');
5
+
6
+ const config = new Conf({
7
+ projectName: 'glad',
8
+ cwd: path.join(os.homedir(), '.glad'),
9
+ configName: 'schedules'
10
+ });
11
+
12
+ function normalizeWeekdays(weekdays) {
13
+ const values = Array.isArray(weekdays) ? weekdays : [];
14
+ return Array.from(new Set(values.map(Number).filter(value => value >= 0 && value <= 6))).sort((a, b) => a - b);
15
+ }
16
+
17
+ function normalizeSteps(steps) {
18
+ const values = Array.isArray(steps) ? steps : [];
19
+ return values.map(step => {
20
+ const type = String(step.type || '').trim();
21
+ if (type === 'sleep') return { type, seconds: Math.max(0, Number(step.seconds) || 0) };
22
+ if (type === 'sendText') return { type, text: String(step.text || '') };
23
+ if (type === 'sendKey') return { type, key: String(step.key || 'enter') };
24
+ if (type === 'keyDown' || type === 'keyUp') return { type, key: String(step.key || '') };
25
+ if (type === 'stop' || type === 'closeSession') return { type };
26
+ return null;
27
+ }).filter(Boolean);
28
+ }
29
+
30
+ function computeNextRunAt(schedule, from = Date.now()) {
31
+ const weekdays = normalizeWeekdays(schedule && schedule.weekdays);
32
+ const time = String(schedule && schedule.time || '').trim();
33
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
34
+ if (!match || weekdays.length === 0) return null;
35
+
36
+ const hour = Number(match[1]);
37
+ const minute = Number(match[2]);
38
+ const start = new Date(from);
39
+
40
+ for (let offset = 0; offset <= 7; offset++) {
41
+ const candidate = new Date(start);
42
+ candidate.setDate(start.getDate() + offset);
43
+ candidate.setHours(hour, minute, 0, 0);
44
+ if (candidate.getTime() <= from) continue;
45
+ if (weekdays.includes(candidate.getDay())) return candidate.getTime();
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ function normalizeJob(input, existing = null, options = {}) {
52
+ const now = Date.now();
53
+ const schedule = input.schedule || {};
54
+ const target = input.target || {};
55
+ const job = {
56
+ id: existing?.id || input.id || uuidv4(),
57
+ name: String(input.name || existing?.name || 'Scheduled Task').trim() || 'Scheduled Task',
58
+ enabled: Boolean(input.enabled ?? existing?.enabled ?? true),
59
+ schedule: {
60
+ time: String(schedule.time || existing?.schedule?.time || '09:00'),
61
+ weekdays: normalizeWeekdays(schedule.weekdays ?? existing?.schedule?.weekdays ?? [1, 2, 3, 4, 5])
62
+ },
63
+ target: {
64
+ toolKey: String(target.toolKey || existing?.target?.toolKey || 'demo'),
65
+ workingDirectory: String(target.workingDirectory ?? existing?.target?.workingDirectory ?? '')
66
+ },
67
+ steps: normalizeSteps(input.steps ?? existing?.steps ?? []),
68
+ createdAt: existing?.createdAt || input.createdAt || now,
69
+ updatedAt: options.touch ? now : (existing?.updatedAt || input.updatedAt || now),
70
+ lastRunAt: existing?.lastRunAt || input.lastRunAt || null,
71
+ lastRunStatus: existing?.lastRunStatus || input.lastRunStatus || 'idle',
72
+ lastRunMessage: existing?.lastRunMessage || input.lastRunMessage || '',
73
+ lastSessionId: existing?.lastSessionId || input.lastSessionId || null,
74
+ running: Boolean(existing?.running || false)
75
+ };
76
+ job.nextRunAt = job.enabled ? computeNextRunAt(job.schedule) : null;
77
+ return job;
78
+ }
79
+
80
+ class JobStore {
81
+ list() {
82
+ return config.get('jobs', []).map(job => normalizeJob(job, job));
83
+ }
84
+
85
+ saveAll(jobs) {
86
+ config.set('jobs', jobs);
87
+ }
88
+
89
+ get(id) {
90
+ return this.list().find(job => job.id === id) || null;
91
+ }
92
+
93
+ create(input) {
94
+ const jobs = this.list();
95
+ const job = normalizeJob(input, null, { touch: true });
96
+ jobs.push(job);
97
+ this.saveAll(jobs);
98
+ return job;
99
+ }
100
+
101
+ update(id, input) {
102
+ const jobs = this.list();
103
+ const index = jobs.findIndex(job => job.id === id);
104
+ if (index === -1) return null;
105
+ jobs[index] = normalizeJob(input, jobs[index], { touch: true });
106
+ this.saveAll(jobs);
107
+ return jobs[index];
108
+ }
109
+
110
+ patchRuntime(id, patch) {
111
+ const jobs = this.list();
112
+ const index = jobs.findIndex(job => job.id === id);
113
+ if (index === -1) return null;
114
+ jobs[index] = { ...jobs[index], ...patch, updatedAt: Date.now() };
115
+ if ('enabled' in patch || 'lastRunAt' in patch || 'schedule' in patch) {
116
+ jobs[index].nextRunAt = jobs[index].enabled ? computeNextRunAt(jobs[index].schedule) : null;
117
+ }
118
+ this.saveAll(jobs);
119
+ return jobs[index];
120
+ }
121
+
122
+ delete(id) {
123
+ const jobs = this.list();
124
+ const next = jobs.filter(job => job.id !== id);
125
+ this.saveAll(next);
126
+ return next.length !== jobs.length;
127
+ }
128
+
129
+ duplicate(id) {
130
+ const source = this.get(id);
131
+ if (!source) return null;
132
+ return this.create({
133
+ ...source,
134
+ id: uuidv4(),
135
+ name: `${source.name} Copy`,
136
+ enabled: false,
137
+ lastRunAt: null,
138
+ lastRunStatus: 'idle',
139
+ lastRunMessage: '',
140
+ lastSessionId: null,
141
+ running: false
142
+ });
143
+ }
144
+ }
145
+
146
+ module.exports = {
147
+ JobStore,
148
+ computeNextRunAt,
149
+ normalizeJob
150
+ };
@@ -0,0 +1,49 @@
1
+ const KEY_SEQUENCES = {
2
+ enter: '\r',
3
+ return: '\r',
4
+ tab: '\t',
5
+ esc: '\x1b',
6
+ escape: '\x1b',
7
+ up: '\x1b[A',
8
+ down: '\x1b[B',
9
+ right: '\x1b[C',
10
+ left: '\x1b[D',
11
+ backspace: '\x7f',
12
+ delete: '\x1b[3~',
13
+ home: '\x1b[H',
14
+ end: '\x1b[F'
15
+ };
16
+
17
+ function ctrlSequence(key) {
18
+ const normalized = String(key || '').trim().toLowerCase();
19
+ if (normalized.length !== 1) return null;
20
+ const code = normalized.charCodeAt(0);
21
+ if (code >= 97 && code <= 122) return String.fromCharCode(code - 96);
22
+ if (normalized === '[') return '\x1b';
23
+ if (normalized === ']') return '\x1d';
24
+ if (normalized === '\\') return '\x1c';
25
+ if (normalized === '^') return '\x1e';
26
+ if (normalized === '_') return '\x1f';
27
+ return null;
28
+ }
29
+
30
+ function sequenceForKey(key, modifiers = {}) {
31
+ const normalized = String(key || '').trim().toLowerCase();
32
+ if (!normalized) return '';
33
+
34
+ if (normalized.startsWith('ctrl+')) {
35
+ return ctrlSequence(normalized.slice(5)) || '';
36
+ }
37
+
38
+ if (modifiers.ctrl) {
39
+ const sequence = ctrlSequence(normalized);
40
+ if (sequence) return sequence;
41
+ }
42
+
43
+ const sequence = KEY_SEQUENCES[normalized] || normalized;
44
+ return modifiers.alt ? '\x1b' + sequence : sequence;
45
+ }
46
+
47
+ module.exports = {
48
+ sequenceForKey
49
+ };
@@ -0,0 +1,39 @@
1
+ class SchedulerService {
2
+ constructor({ jobStore, jobRunner, logger, intervalMs = 30000 }) {
3
+ this.jobStore = jobStore;
4
+ this.jobRunner = jobRunner;
5
+ this.logger = logger || console;
6
+ this.intervalMs = intervalMs;
7
+ this.timer = null;
8
+ }
9
+
10
+ start() {
11
+ if (this.timer) return;
12
+ this.timer = setInterval(() => {
13
+ this.tick();
14
+ }, this.intervalMs);
15
+ }
16
+
17
+ stop() {
18
+ if (!this.timer) return;
19
+ clearInterval(this.timer);
20
+ this.timer = null;
21
+ }
22
+
23
+ tick(now = Date.now()) {
24
+ for (const job of this.jobStore.list()) {
25
+ if (!job.enabled || !job.nextRunAt || job.nextRunAt > now) continue;
26
+ this.jobStore.patchRuntime(job.id, {
27
+ nextRunAt: null,
28
+ lastRunAt: now,
29
+ lastRunStatus: 'queued',
30
+ lastRunMessage: ''
31
+ });
32
+ this.jobRunner.run(job.id, { manual: false }).catch(error => {
33
+ this.logger.error(`Scheduled task ${job.id} failed: ${error.message}`);
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ module.exports = SchedulerService;
@@ -0,0 +1,102 @@
1
+ const logger = require('../utils/logger');
2
+
3
+ class CircularBuffer {
4
+ constructor(maxSize = 100000) {
5
+ this.maxSize = maxSize; // 100KB default
6
+ this.buffer = [];
7
+ this.totalSize = 0;
8
+ this.currentSeq = 0;
9
+ }
10
+
11
+ previewText(text, maxChars = 240) {
12
+ if (!text) return '';
13
+ const normalized = String(text)
14
+ .replace(/\r/g, '\\r')
15
+ .replace(/\n/g, '\\n')
16
+ .replace(/\t/g, '\\t')
17
+ .replace(/\x1b/g, '\\x1b');
18
+ return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
19
+ }
20
+
21
+ // Calculate byte size of data
22
+ getByteSize(data) {
23
+ return Buffer.byteLength(data, 'utf8');
24
+ }
25
+
26
+ // Append data to buffer
27
+ append(data) {
28
+ const timestamp = new Date().toISOString();
29
+ const seq = ++this.currentSeq;
30
+ const byteSize = this.getByteSize(data);
31
+
32
+ const item = {
33
+ seq,
34
+ data,
35
+ timestamp,
36
+ size: byteSize
37
+ };
38
+
39
+ this.buffer.push(item);
40
+ this.totalSize += byteSize;
41
+
42
+ // Remove old items if buffer exceeds max size
43
+ while (this.totalSize > this.maxSize && this.buffer.length > 0) {
44
+ const removed = this.buffer.shift();
45
+ this.totalSize -= removed.size;
46
+ }
47
+
48
+ return item;
49
+ }
50
+
51
+ // Get all items after a specific sequence number
52
+ getAfter(seq) {
53
+ return this.buffer.filter(item => item.seq > seq);
54
+ }
55
+
56
+ // Get all items
57
+ getAll() {
58
+ return [...this.buffer];
59
+ }
60
+
61
+ // Get current sequence number
62
+ getCurrentSeq() {
63
+ return this.currentSeq;
64
+ }
65
+
66
+ // Clear buffer
67
+ clear() {
68
+ logger.debug('Buffer: cleared');
69
+ this.buffer = [];
70
+ this.totalSize = 0;
71
+ }
72
+
73
+ // Get buffer stats
74
+ getStats() {
75
+ return {
76
+ items: this.buffer.length,
77
+ totalSize: this.totalSize,
78
+ maxSize: this.maxSize,
79
+ currentSeq: this.currentSeq,
80
+ oldestSeq: this.buffer.length > 0 ? this.buffer[0].seq : null,
81
+ newestSeq: this.buffer.length > 0 ? this.buffer[this.buffer.length - 1].seq : null
82
+ };
83
+ }
84
+
85
+ getDebugSnapshot(maxItems = 5, maxChars = 240) {
86
+ return {
87
+ ...this.getStats(),
88
+ tailItems: this.buffer.slice(-maxItems).map(item => ({
89
+ seq: item.seq,
90
+ size: item.size,
91
+ timestamp: item.timestamp,
92
+ preview: this.previewText(item.data, maxChars)
93
+ })),
94
+ combinedTailPreview: this.previewText(
95
+ this.buffer.slice(-maxItems).map(item => item.data).join(''),
96
+ maxChars
97
+ )
98
+ };
99
+ }
100
+ }
101
+
102
+ module.exports = CircularBuffer;