merlyn/server/endpoints/agentFileServer.js
Marcello Fitton 41495cdabe
feat: Scheduled Jobs (#5322)
* initialize

* expand tool result text limit | add syntax highlighting and json formatting to tool result rendering

* fix onError jsdoc

* lint

* fix unread icon

* route protection

* improve form handling for NewJobModal

* safeJsonParse

* remove unneeded comments

* remove trycatch

* add truncateText helper

* add explicit fallback value tos safeJsonParse

* add shared cron constant and helpers

* reduce frontend indirection

* use isLight to compute syntax highlighting theme

* remove dead code

* remove forJob and make job limit to 50

* create recomputeNextRunAt helper method

* add comment about nextRunAt recomputation

* add job queue and concurrency control to scheduled jobs

* use p-queue

* change default max concurrent value to 1

* add comment explaining internal scheduling system

* add recomputeNextRunAt on boot

* add generated documents to run details

* Modify toolsOverride functionality where no tools selected means no tools are given to the agent

add a select all/deselect all toggle button for easily selecting all
tools in the cerate job form

* create usePolling hook

* add polling to scheduled jobs and scheduled job runs pages

* add cron generation feature in job form

* remove cron generation feature | add cron builder feature | add max active scheduled jobs limit

* set MAX_ACTIVE to null

* replace hour and minute input fields with input with type time

* simplify

* organize components

* move components to bottom of page component

* change Generated Documents to Generated Files

* add i18n to cronstrue

* add i18n

* add type="button" to button elements

* refactor fileSource retrieval logic

* one scheduled job run can have status "running"

* add protection of file retrieveal from scheduled job in multiuser mode

* fix comments

* make job status default to queued

* add queued status

* fix bug with result trace rendering

* store timeout ref and clearTimeout once race settles

* remove unneeded handlerPromise tracking

* move imports to top level

* refactor hardcoded paths to path resolve functions

* implement new job form design

* simplify

* fix button styles

* fix runJob bug

* implement styles for scheduled jobs page

* apply dark mode figma styles

* delete unused translation key

* implement light mode for new new job modal, run history, and run details

* lint

* fix light mode scroll bar in tool call card

* adjust table header contrast

* fix type in subtitle

* kill workers when job is in-flight before deleting job

* add border-none to buttons

* change locale time to iso string

* import BackgroundService module level | instatiate backgroundService singltone once and reuse across handlers

* add p-queue, @breejs/later and cron-validate as core deps

* parse cron expression to a builder state once

* add theme to day buttons in cron builder

* fix stale tools selection caption

* flip popover when popover clips screen height

* make ScheduleJob.trigger() await the run insertion | disable run now button if job is in flight

* regen table

* refactor generated file card

* refactor frontend

* remove logs

* major refactor for tool picking, fix bree/later bug

* combine action endpoints, move contine to method

* fix unoptimized query with include + take + order

* fix dangerous use, refactor job to utils

* add copy content to text response

* improve notification system subscription for browser

* remove unused translations

* prevent gen-file cleanup job from deleting active job file generated references

* rich text copy

* Scheduled Jobs: Translations (#5482)

* add locales for scheduled jobs

* i18n

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* add config flag with UI notice

* update README

* telemetry datapoints

* Always use UTC on backend, convert to local in frontend

* fix tz render

* Add job killing

* cleanup thinking text in job notifications and break out reasoning in response text.
Also hide zero metrics since that is useless

* Port generatedFile schema to the normalized workspace chat `outputs` file format so porting to thread is simple and implem between chats <> jobs is 1:1

* what the fuck

* compiled bug

* fixed thinking oddity in complied frontend

* supress multi-toast

* fix duration call

* Revert "fix duration call"

This reverts commit 0491bc71f4223e65ea4046561b15b268fefb8da2.

* revert and reapply fix

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
2026-04-29 12:05:46 -07:00

179 lines
5.7 KiB
JavaScript

const {
userFromSession,
multiUserMode,
safeJsonParse,
} = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { WorkspaceChats } = require("../models/workspaceChats");
const { Workspace } = require("../models/workspace");
const { ScheduledJobRun } = require("../models/scheduledJobRun");
const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib");
const { Telemetry } = require("../models/telemetry");
/**
* Endpoints for serving agent-generated files (PPTX, etc.) with authentication
* and ownership validation.
*/
function agentFileServerEndpoints(app) {
if (!app) return;
/**
* Download a generated file by its storage filename.
* Validates that the requesting user has access to the workspace
* where the file was generated.
*/
app.get(
"/agent-skills/generated-files/:filename",
[validatedRequest, flexUserRoleValid([ROLES.all])],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const { filename } = request.params;
if (!filename)
return response.status(400).json({ error: "Filename is required" });
// Validate filename format
const parsed = createFilesLib.parseFilename(filename);
if (!parsed) {
return response
.status(400)
.json({ error: "Invalid filename format" });
}
// Find a chat or scheduled job run that references this file
const fileSource = await findFileSource(filename, {
user,
isMultiUser: multiUserMode(response),
});
if (!fileSource) {
return response.status(404).json({
error: "File not found or access denied",
});
}
// Retrieve the file from storage
const fileData = await createFilesLib.getGeneratedFile(filename);
if (!fileData) {
return response
.status(404)
.json({ error: "File not found in storage" });
}
// Get mime type and set headers for download
const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`);
const safeFilename = createFilesLib.sanitizeFilenameForHeader(
fileSource.displayFilename || filename
);
response.setHeader("Content-Type", mimeType);
response.setHeader(
"Content-Disposition",
`attachment; filename="${safeFilename}"`
);
response.setHeader("Content-Length", fileData.buffer.length);
response.send(fileData.buffer);
Telemetry.sendTelemetry("agent_generated_file_downloaded", {
type: mimeType,
}).catch(() => {});
return;
} catch (error) {
console.error("[agentFileServer] Download error:", error.message);
return response.status(500).json({ error: "Failed to download file" });
}
}
);
}
/**
* Locates the source record (a workspace chat or a scheduled job run) that
* references the given storage filename, and confirms the requester has access.
*
* Search order:
* 1. Workspace chats the user can access (per multi-user permissions).
* 2. Scheduled job runs — single-user only, so no per-user access check.
*
* @param {string} storageFilename
* @param {{ user: object|null, isMultiUser: boolean }} ctx
* @returns {Promise<{workspaceId: number|null, displayFilename: string}|null>}
*/
async function findFileSource(storageFilename, { user, isMultiUser }) {
try {
const fromChat = await findInWorkspaceChats(storageFilename, {
user,
isMultiUser,
});
if (fromChat) return fromChat;
if (isMultiUser) return null;
return await findInScheduledJobRuns(storageFilename);
} catch (error) {
console.error("[findFileSource] Error:", error.message);
return null;
}
}
// Search workspace chats the user has access to. In single-user mode all
// workspaces are accessible; in multi-user mode only workspaces assigned to
// the user are. Returns the matching chat's workspace + display filename.
async function findInWorkspaceChats(storageFilename, { user, isMultiUser }) {
const workspaces =
isMultiUser && user
? await Workspace.whereWithUser(user)
: await Workspace.where();
const workspaceIds = workspaces.map((w) => w.id);
if (workspaceIds.length === 0) return null;
// DB-level filter so we don't load every chat into memory.
const chats = await WorkspaceChats.where({
workspaceId: { in: workspaceIds },
include: true,
response: { contains: storageFilename },
});
for (const chat of chats) {
const { outputs = [] } = safeJsonParse(chat.response, { outputs: [] });
const output = outputs.find(
(o) => o?.payload?.storageFilename === storageFilename
);
if (!output) continue;
return {
workspaceId: chat.workspaceId,
displayFilename:
output.payload.filename || output.payload.displayFilename,
};
}
return null;
}
// Search completed scheduled job runs. Scheduled jobs are single-user only,
// so this skips access control. Returns the matching run's display filename.
async function findInScheduledJobRuns(storageFilename) {
const runs = await ScheduledJobRun.where({
status: "completed",
result: { contains: storageFilename },
});
for (const run of runs) {
const { outputs = [] } = safeJsonParse(run.result, { outputs: [] });
const output = outputs.find(
(o) => o?.payload?.storageFilename === storageFilename
);
if (!output) continue;
return {
workspaceId: null,
displayFilename: output.payload.filename || storageFilename,
};
}
return null;
}
module.exports = { agentFileServerEndpoints };