@goodandready/dsh-cron 0.3.25 → 0.3.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.
Files changed (82) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -191
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/commands.js +200 -0
  32. package/lib/durable-journal.js +1 -188
  33. package/lib/external-api.js +1 -74
  34. package/lib/index.js +17 -0
  35. package/lib/metrics.js +1 -125
  36. package/lib/routes/api-actions.js +317 -0
  37. package/lib/routes/api-collection.js +250 -0
  38. package/lib/routes/api-helpers.js +100 -0
  39. package/lib/routes/api-import-export.js +155 -0
  40. package/lib/routes/api-item.js +191 -0
  41. package/lib/routes/api-task-webhook.js +247 -0
  42. package/lib/routes/api-webhook.js +183 -0
  43. package/lib/routes/api.js +178 -0
  44. package/lib/routes/external-api.js +74 -0
  45. package/lib/routes/index.js +11 -0
  46. package/lib/routes/metrics.js +125 -0
  47. package/lib/routes/routes.js +549 -0
  48. package/lib/routes.js +1 -549
  49. package/lib/runner-external.js +1 -352
  50. package/lib/runner-worktree.js +1 -90
  51. package/lib/runner.js +1 -881
  52. package/lib/runtimes/index.js +4 -0
  53. package/lib/runtimes/runner-external.js +352 -0
  54. package/lib/runtimes/runner-worktree.js +90 -0
  55. package/lib/runtimes/runner.js +881 -0
  56. package/lib/runtimes/runtimes.js +198 -0
  57. package/lib/runtimes.js +1 -198
  58. package/lib/scheduler/index.js +6 -0
  59. package/lib/scheduler/scheduler-assists.js +142 -0
  60. package/lib/scheduler/scheduler-completion.js +417 -0
  61. package/lib/scheduler/scheduler-execution.js +360 -0
  62. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  63. package/lib/scheduler/scheduler-preflight.js +195 -0
  64. package/lib/scheduler/scheduler.js +751 -0
  65. package/lib/scheduler-assists.js +1 -142
  66. package/lib/scheduler-completion.js +1 -417
  67. package/lib/scheduler-execution.js +1 -360
  68. package/lib/scheduler-lifecycle.js +1 -157
  69. package/lib/scheduler-preflight.js +1 -195
  70. package/lib/scheduler.js +1 -751
  71. package/lib/store/artifacts.js +200 -0
  72. package/lib/store/chain-checkpoints.js +223 -0
  73. package/lib/store/durable-journal.js +188 -0
  74. package/lib/store/index.js +6 -0
  75. package/lib/store/store.js +1179 -0
  76. package/lib/store/task-schema.js +180 -0
  77. package/lib/store/task-transfer.js +310 -0
  78. package/lib/store.js +2 -1179
  79. package/lib/task-schema.js +1 -180
  80. package/lib/task-transfer.js +1 -310
  81. package/lib/tui-settings.js +157 -0
  82. package/package.json +2 -1
@@ -1,183 +1 @@
1
- import crypto from 'node:crypto';
2
- import { logger } from './logger.js';
3
- import { sendJson, readBody } from './http-utils.js';
4
- import { answerTelegramCallbackQuery, sendTelegramMessage, getDshDefaultTelegramCredentials } from './telegram.js';
5
- import { handleTelegramCommand } from './telegram-commands.js';
6
- import { bestEffort } from './best-effort.js';
7
-
8
- const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
9
- const seenUpdateIds = new Set();
10
-
11
- export async function handleTelegramWebhook({ store, scheduler, req, res }) {
12
- if (req.method !== 'POST') {
13
- sendJson(res, 405, NOT_ALLOWED);
14
- return;
15
- }
16
- const { body, error } = await readBody(req, res);
17
- if (error || !body) return;
18
-
19
- const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
20
- const defaults = getDshDefaultTelegramCredentials();
21
- const botToken = settings.botToken || defaults.botToken;
22
- const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
23
-
24
- // Secret token verification (Issue #208) - fail-closed: secret MUST be configured
25
- const webhookSecret = settings.telegramWebhookSecret || settings.webhookSecret || settings.secretToken || process.env.TELEGRAM_WEBHOOK_SECRET;
26
- if (!webhookSecret) {
27
- logger.warn('[dsh-cron] unauthorized telegram webhook: webhook secret is not configured (fail-closed)');
28
- sendJson(res, 401, { ok: false, error: 'Unauthorized: webhook secret is not configured' });
29
- return;
30
- }
31
- const headers = req.headers || {};
32
- const receivedSecret = headers['x-telegram-bot-api-secret-token'] || headers['X-Telegram-Bot-Api-Secret-Token'];
33
- if (!receivedSecret || typeof receivedSecret !== 'string') {
34
- logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
35
- sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
36
- return;
37
- }
38
- const secretBuf = Buffer.from(String(webhookSecret));
39
- const receivedBuf = Buffer.from(String(receivedSecret));
40
- if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
41
- logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
42
- sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
43
- return;
44
- }
45
-
46
- // Deduplicate updates by update_id (Issue #208)
47
- if (body.update_id != null) {
48
- if (seenUpdateIds.has(body.update_id)) {
49
- sendJson(res, 200, { ok: true, duplicate: true });
50
- return;
51
- }
52
- seenUpdateIds.add(body.update_id);
53
- if (seenUpdateIds.size > 2000) {
54
- const first = seenUpdateIds.values().next().value;
55
- seenUpdateIds.delete(first);
56
- }
57
- }
58
-
59
- // 1. Text command message (#175)
60
- const message = body.message;
61
- if (message && typeof message.text === 'string' && message.text.trim().startsWith('/')) {
62
- const fromId = String(message.from?.id || '');
63
- const chatId = String(message.chat?.id || fromId);
64
- // Fail-closed: empty allowedChatId denies control (Issue #208)
65
- if (!allowedChatId || (chatId !== allowedChatId && fromId !== allowedChatId)) {
66
- logger.warn(`[dsh-cron] unauthorized telegram command from chat ${chatId} / user ${fromId} (allowedChatId: ${allowedChatId || 'NOT_CONFIGURED'})`);
67
- sendJson(res, 403, { ok: false, error: 'Forbidden' });
68
- return;
69
- }
70
- const result = await handleTelegramCommand({
71
- store,
72
- scheduler,
73
- text: message.text,
74
- chatId,
75
- botToken,
76
- });
77
- sendJson(res, 200, result || { ok: true });
78
- return;
79
- }
80
-
81
- // 2. Inline callback query (#137)
82
- const callbackQuery = body.callback_query;
83
- if (!callbackQuery) {
84
- sendJson(res, 200, { ok: true, ignored: true });
85
- return;
86
- }
87
-
88
- const queryId = callbackQuery.id;
89
- const data = String(callbackQuery.data || '');
90
- const fromId = String(callbackQuery.from?.id || '');
91
- const chatId = String(callbackQuery.message?.chat?.id || fromId);
92
-
93
- if (!allowedChatId || (chatId !== allowedChatId && fromId !== allowedChatId)) {
94
- logger.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId} (allowedChatId: ${allowedChatId || 'NOT_CONFIGURED'})`);
95
- if (botToken) {
96
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unauthorized', showAlert: true });
97
- }
98
- sendJson(res, 403, { ok: false, error: 'Forbidden' });
99
- return;
100
- }
101
-
102
- const parts = data.split(':');
103
- if (parts[0] !== 'cron' || parts.length < 3) {
104
- if (botToken) {
105
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unknown action' });
106
- }
107
- sendJson(res, 200, { ok: true, ignored: true });
108
- return;
109
- }
110
-
111
- const verb = parts[1];
112
- const taskId = parts.slice(2).join(':');
113
- const task = store.get(taskId);
114
-
115
- if (!task) {
116
- if (botToken) {
117
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Task not found', showAlert: true });
118
- }
119
- sendJson(res, 200, { ok: false, error: 'Task not found' });
120
- return;
121
- }
122
-
123
- const isConfigTask = task.source === 'config' || task.origin === 'config' || task.readOnly || task.managedBy === 'config';
124
- if ((verb === 'pause' || verb === 'resume') && isConfigTask) {
125
- if (botToken) {
126
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Cannot modify config-owned task', showAlert: true });
127
- }
128
- sendJson(res, 400, { ok: false, error: 'Cannot modify config-owned task' });
129
- return;
130
- }
131
-
132
- if (verb === 'run') {
133
- try {
134
- await scheduler.triggerManualRun(taskId);
135
- if (botToken) {
136
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `🚀 Run triggered: ${task.title}` });
137
- }
138
- } catch (runErr) {
139
- if (botToken) {
140
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `Run error: ${runErr.message}`, showAlert: true });
141
- }
142
- }
143
- } else if (verb === 'pause') {
144
- const isPaused = task.status === 'paused';
145
- if (isPaused) {
146
- scheduler.resumeTask(taskId);
147
- if (botToken) {
148
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `▶️ Resumed: ${task.title}` });
149
- }
150
- } else {
151
- scheduler.pauseTask(taskId, 'Paused via Telegram button');
152
- if (botToken) {
153
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `⏸ Paused: ${task.title}` });
154
- }
155
- }
156
- } else if (verb === 'log') {
157
- const history = store.getHistory(taskId, 1);
158
- const lastRun = history && history[0];
159
- if (!lastRun) {
160
- if (botToken) {
161
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `No runs recorded for ${task.title}`, showAlert: true });
162
- }
163
- } else {
164
- const summary = `[${task.title}] Last run: ${lastRun.status} (${lastRun.durationMs}ms)\n${lastRun.output || lastRun.error || 'Empty output'}`;
165
- if (botToken) {
166
- await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: summary.slice(0, 190) });
167
- if (summary.length > 190 && chatId) {
168
- await sendTelegramMessage({
169
- botToken,
170
- chatId,
171
- text: `*Full Output for ${task.title}:*\n\`\`\`\n${summary.slice(0, 3000)}\n\`\`\``,
172
- parseMode: 'Markdown',
173
- }).catch(() => {
174
- bestEffort('telegram-full-output', () => {}, scheduler?.logger);
175
- });
176
- }
177
- }
178
- }
179
- }
180
-
181
- sendJson(res, 200, { ok: true, verb, taskId });
182
- }
183
-
1
+ export * from './routes/api-webhook.js';
package/lib/api.js CHANGED
@@ -1,178 +1 @@
1
- import { handleTaskWebhookRoute } from './api-task-webhook.js';
2
- import { sendJson, rejectCrossOrigin, redactTaskSecrets } from './http-utils.js';
3
- import {
4
- handleTaskExport,
5
- handleTaskImport,
6
- applyImportPlan,
7
- } from './api-import-export.js';
8
- import { handleTelegramWebhook } from './api-webhook.js';
9
- import {
10
- handleHeartbeatPing,
11
- handleSchedulePreview,
12
- handleDryRunTask,
13
- handleScheduleTimeline,
14
- } from './api-helpers.js';
15
- import {
16
- refuseConfigOwned,
17
- handleTaskCollection,
18
- createOrUpdateTask,
19
- NOT_ALLOWED,
20
- NOT_FOUND,
21
- } from './api-collection.js';
22
- import { handleTaskItem, deleteTask } from './api-item.js';
23
- import { handleItemPost } from './api-actions.js';
24
-
25
- export {
26
- handleTaskExport,
27
- handleTaskImport,
28
- applyImportPlan,
29
- handleTelegramWebhook,
30
- handleHeartbeatPing,
31
- handleSchedulePreview,
32
- handleDryRunTask,
33
- handleScheduleTimeline,
34
- refuseConfigOwned,
35
- };
36
-
37
- /**
38
- * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
39
- * route families — the #66 alias is kept alive by the same code path instead
40
- * of a duplicated handler.
41
- *
42
- * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
43
- * with one, the core degrades matching to exact-path equality and every
44
- * deeper path unrouted (#96). Exported for unit tests.
45
- */
46
- export function createCronApiHandler(store, scheduler, collection) {
47
- const ctx = collection?.ctx;
48
- return async function handleCronApi(req, res) {
49
- try {
50
- const apiToken = store?.getSettings?.()?.apiToken;
51
- const authOptions = { apiToken, ctx };
52
- const url = new URL(req.url, 'http://127.0.0.1');
53
- const parts = url.pathname.split('/').filter(Boolean);
54
- const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
55
- const id = parts[2];
56
- const action = parts[3];
57
- const subAction = parts[4];
58
-
59
- if (scope === 'telegram' && id === 'webhook') {
60
- await handleTelegramWebhook({ store, scheduler, req, res });
61
- return;
62
- }
63
- if (scope === 'webhook' && id) {
64
- await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: id });
65
- return;
66
- }
67
-
68
- if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
69
- handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
70
- return;
71
- }
72
-
73
- if (scope === 'schedule' && id === 'preview') {
74
- await handleSchedulePreview({ req, res, url, store, apiToken, ctx });
75
- return;
76
- }
77
- if (scope === 'schedule' && id === 'timeline-24h') {
78
- await handleScheduleTimeline({ req, res, url, store, scheduler, apiToken, ctx });
79
- return;
80
- }
81
-
82
- if (!id) {
83
- await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx });
84
- return;
85
- }
86
-
87
- if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
88
-
89
- // Export/import are collection operations that live under /tasks/:id/…,
90
- // so they are matched before the generic id handling below.
91
- if (id === 'export') {
92
- if (req.method !== 'GET') {
93
- sendJson(res, 405, NOT_ALLOWED);
94
- return;
95
- }
96
- handleTaskExport({ store, req, res });
97
- return;
98
- }
99
- if (id === 'import') {
100
- if (req.method !== 'POST') {
101
- sendJson(res, 405, NOT_ALLOWED);
102
- return;
103
- }
104
- await handleTaskImport({ store, scheduler, req, res });
105
- return;
106
- }
107
-
108
- await handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx });
109
- } catch (err) {
110
- sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
111
- }
112
- };
113
- }
114
-
115
- // --------------------------------------------------------- external surface
116
-
117
- /**
118
- * Task operations for the token-guarded /dsh-cron/api/* prefix (#54). The shape
119
- * is deliberately small — list, read, create or update, delete, manual run —
120
- * and reuses the handlers above, so the confirmation gate for code-executing
121
- * tasks and the config-owned refusals are the same code, not a second copy.
122
- */
123
- export async function handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken }) {
124
- const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, [tasks|heartbeat|schedule], :id?, :action?
125
- if (parts[2] === 'webhook' && parts[3]) {
126
- await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: parts[3] });
127
- return;
128
- }
129
- if (parts[2] === 'heartbeat' && parts[3]) {
130
- handleHeartbeatPing({ store, req, res, taskId: parts[3], apiToken });
131
- return;
132
- }
133
- if (parts[2] === 'schedule' && parts[3] === 'preview') {
134
- await handleSchedulePreview({ req, res, url, store, apiToken });
135
- return;
136
- }
137
- const id = parts[3];
138
- const action = parts[4];
139
- const subAction = parts[5];
140
-
141
- if (!id) {
142
- if (req.method === 'GET') {
143
- const list = store
144
- .list({ status: url.searchParams.get('status') || 'all', query: url.searchParams.get('query') || '' })
145
- .map((task) => ({ ...redactTaskSecrets(task), running: scheduler.isRunning(task.id) }));
146
- sendJson(res, 200, { ok: true, tasks: list });
147
- return;
148
- }
149
- if (req.method === 'POST') {
150
- await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
151
- return;
152
- }
153
- sendJson(res, 405, NOT_ALLOWED);
154
- return;
155
- }
156
-
157
- if (req.method === 'POST') {
158
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
159
- if (handled) return;
160
- }
161
- if (req.method === 'GET' && !action) {
162
- const task = store.get(id);
163
- if (!task) {
164
- sendJson(res, 404, NOT_FOUND);
165
- return;
166
- }
167
- sendJson(res, 200, { ok: true, task: { ...redactTaskSecrets(task), running: scheduler.isRunning(id) } });
168
- return;
169
- }
170
- if (req.method === 'DELETE' && !action) {
171
- deleteTask({ store, scheduler, res, id });
172
- return;
173
- }
174
- sendJson(res, 405, NOT_ALLOWED);
175
- }
176
-
177
- // webhook and helper endpoints moved to ./api-webhook.js and ./api-helpers.js
178
-
1
+ export * from './routes/api.js';
package/lib/artifacts.js CHANGED
@@ -1,200 +1 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { createHash, randomUUID } from 'node:crypto';
4
-
5
- export const SAFE_MIME_TYPES = {
6
- '.csv': 'text/csv; charset=utf-8',
7
- '.json': 'application/json; charset=utf-8',
8
- '.txt': 'text/plain; charset=utf-8',
9
- '.log': 'text/plain; charset=utf-8',
10
- '.md': 'text/markdown; charset=utf-8',
11
- '.pdf': 'application/pdf',
12
- '.png': 'image/png',
13
- '.jpg': 'image/jpeg',
14
- '.jpeg': 'image/jpeg',
15
- '.webp': 'image/webp',
16
- };
17
-
18
- export class ArtifactManager {
19
- constructor(baseDir) {
20
- this.baseDir = baseDir || path.join(process.cwd(), 'artifacts');
21
- this.maxArtifactBytes = 10 * 1024 * 1024; // 10 MiB per artifact
22
- this.defaultRetentionDays = 7;
23
- this.maxTotalStorageBytes = 50 * 1024 * 1024; // 50 MiB total quota
24
- }
25
-
26
- ensureDir() {
27
- if (!fs.existsSync(this.baseDir)) {
28
- fs.mkdirSync(this.baseDir, { recursive: true });
29
- }
30
- }
31
-
32
- /**
33
- * Sanitizes a requested filename to prevent path traversal (#321)
34
- */
35
- sanitizeFilename(name) {
36
- if (!name || typeof name !== 'string') return 'artifact.txt';
37
- const clean = path.basename(name).replace(/[\0\r\n\\\/]/g, '_');
38
- return clean || 'artifact.txt';
39
- }
40
-
41
- getFilePath(artifactId, filename) {
42
- if (!filename || filename.includes('..') || path.isAbsolute(filename)) {
43
- throw new Error('Path traversal attempt detected in artifact storage');
44
- }
45
- const safeName = this.sanitizeFilename(filename);
46
- const target = path.resolve(this.baseDir, `${artifactId}_${safeName}`);
47
- // Security check: ensure path stays strictly within baseDir
48
- if (!target.startsWith(path.resolve(this.baseDir) + path.sep)) {
49
- throw new Error('Path traversal attempt detected in artifact storage');
50
- }
51
- return target;
52
- }
53
-
54
- /**
55
- * Resolves safe MIME type for download/preview
56
- */
57
- resolveMimeType(filename) {
58
- const ext = path.extname(filename).toLowerCase();
59
- // Neutralize dangerous web formats to prevent XSS / script execution
60
- if (ext === '.html' || ext === '.htm' || ext === '.svg' || ext === '.js') {
61
- return 'text/plain; charset=utf-8';
62
- }
63
- return SAFE_MIME_TYPES[ext] || 'application/octet-stream';
64
- }
65
-
66
- /**
67
- * Saves a new artifact file bound to a run (#321)
68
- */
69
- saveArtifact(runId, taskId, filename, contentBuffer) {
70
- this.ensureDir();
71
- const buf = Buffer.isBuffer(contentBuffer) ? contentBuffer : Buffer.from(contentBuffer || '', 'utf8');
72
-
73
- if (buf.length > this.maxArtifactBytes) {
74
- throw new Error(`Artifact size (${buf.length} bytes) exceeds limit of ${this.maxArtifactBytes} bytes`);
75
- }
76
-
77
- const artifactId = 'art_' + randomUUID().replace(/-/g, '').slice(0, 16);
78
- const safeName = this.sanitizeFilename(filename);
79
- const filePath = this.getFilePath(artifactId, safeName);
80
-
81
- fs.writeFileSync(filePath, buf);
82
-
83
- const sha256 = createHash('sha256').update(buf).digest('hex');
84
- const mimeType = this.resolveMimeType(safeName);
85
-
86
- return {
87
- id: artifactId,
88
- name: safeName,
89
- sizeBytes: buf.length,
90
- mimeType,
91
- sha256,
92
- runId,
93
- taskId,
94
- createdAt: Date.now(),
95
- status: 'available',
96
- };
97
- }
98
-
99
- /**
100
- * Reads an artifact file buffer
101
- */
102
- readArtifact(artifactId, filename) {
103
- const filePath = this.getFilePath(artifactId, filename);
104
- if (!fs.existsSync(filePath)) {
105
- return null;
106
- }
107
- return fs.readFileSync(filePath);
108
- }
109
-
110
- /**
111
- * Generates a safe preview of the artifact content (first 64 KiB)
112
- */
113
- generatePreview(artifactId, filename) {
114
- const buf = this.readArtifact(artifactId, filename);
115
- if (!buf) return { ok: false, error: 'Artifact file not found on disk' };
116
-
117
- const ext = path.extname(filename).toLowerCase();
118
- const isText = ['.txt', '.csv', '.json', '.log', '.md'].includes(ext);
119
-
120
- if (isText) {
121
- const slice = buf.subarray(0, 64 * 1024);
122
- return {
123
- ok: true,
124
- kind: 'text',
125
- mimeType: this.resolveMimeType(filename),
126
- content: slice.toString('utf8'),
127
- truncated: buf.length > slice.length,
128
- sizeBytes: buf.length,
129
- };
130
- }
131
-
132
- return {
133
- ok: true,
134
- kind: 'binary',
135
- mimeType: this.resolveMimeType(filename),
136
- sizeBytes: buf.length,
137
- preview: `[Binary file: ${filename} (${buf.length} bytes)]`,
138
- };
139
- }
140
-
141
- /**
142
- * Cleans up expired artifacts and enforces size quotas (#321)
143
- */
144
- cleanupArtifacts(store, { retentionDays = this.defaultRetentionDays, maxTotalBytes = this.maxTotalStorageBytes } = {}) {
145
- if (!fs.existsSync(this.baseDir)) return { removedCount: 0, freedBytes: 0 };
146
-
147
- const cutoff = Date.now() - retentionDays * 86400000;
148
- let files = fs.readdirSync(this.baseDir);
149
- let totalBytes = 0;
150
- const fileStats = [];
151
-
152
- for (const f of files) {
153
- const p = path.join(this.baseDir, f);
154
- try {
155
- const stat = fs.statSync(p);
156
- totalBytes += stat.size;
157
- fileStats.push({ path: p, filename: f, mtime: stat.mtimeMs, size: stat.size });
158
- } catch (e) {
159
- // file unlinked concurrently
160
- }
161
- }
162
-
163
- let removedCount = 0;
164
- let freedBytes = 0;
165
-
166
- // 1. Remove files older than retentionDays
167
- for (const item of fileStats) {
168
- if (item.mtime < cutoff) {
169
- try {
170
- fs.unlinkSync(item.path);
171
- removedCount++;
172
- freedBytes += item.size;
173
- totalBytes -= item.size;
174
- } catch (e) {
175
- void e;
176
- }
177
- }
178
- }
179
-
180
- // 2. Enforce quota: remove oldest files if totalBytes > maxTotalBytes
181
- if (totalBytes > maxTotalBytes) {
182
- fileStats.sort((a, b) => a.mtime - b.mtime);
183
- for (const item of fileStats) {
184
- if (totalBytes <= maxTotalBytes) break;
185
- if (fs.existsSync(item.path)) {
186
- try {
187
- fs.unlinkSync(item.path);
188
- removedCount++;
189
- freedBytes += item.size;
190
- totalBytes -= item.size;
191
- } catch (e) {
192
- void e;
193
- }
194
- }
195
- }
196
- }
197
-
198
- return { removedCount, freedBytes };
199
- }
200
- }
1
+ export * from './store/artifacts.js';