teamcopilot 0.3.6 → 0.4.1

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 (65) hide show
  1. package/dist/chat/index.js +104 -81
  2. package/dist/cronjob/index.js +2 -0
  3. package/dist/cronjobs/index.js +822 -0
  4. package/dist/cronjobs/scheduler.js +936 -0
  5. package/dist/frontend/assets/{cssMode-BRVRAYCz.js → cssMode-Cqdl5sUM.js} +1 -1
  6. package/dist/frontend/assets/{freemarker2-B5FvHwsO.js → freemarker2-ykAhuplU.js} +1 -1
  7. package/dist/frontend/assets/{handlebars-DWX2asql.js → handlebars-DX_JwRM8.js} +1 -1
  8. package/dist/frontend/assets/{html-BEBxxD9G.js → html-Bi_zOcbU.js} +1 -1
  9. package/dist/frontend/assets/{htmlMode-B2LbPTwC.js → htmlMode-CkAUoAah.js} +1 -1
  10. package/dist/frontend/assets/index-Ba9bElZm.css +1 -0
  11. package/dist/frontend/assets/{index-D3TE04C5.js → index-Cgozj4fx.js} +245 -242
  12. package/dist/frontend/assets/{javascript-Bh4JwoPV.js → javascript-D3Rjwp97.js} +1 -1
  13. package/dist/frontend/assets/{jsonMode-7j-aplXT.js → jsonMode-K4i6LjP2.js} +1 -1
  14. package/dist/frontend/assets/{liquid-BP4OxkO7.js → liquid-D8F4-sAz.js} +1 -1
  15. package/dist/frontend/assets/{mdx-C1OIcGbY.js → mdx-C2xw8PNz.js} +1 -1
  16. package/dist/frontend/assets/{python-BO8Wy5jz.js → python-CqTGfu2v.js} +1 -1
  17. package/dist/frontend/assets/{razor-BDtqXvAH.js → razor-DFSsPzdZ.js} +1 -1
  18. package/dist/frontend/assets/{tsMode-D22HcCuX.js → tsMode-BkLQEtPb.js} +1 -1
  19. package/dist/frontend/assets/{typescript-CagwEzRw.js → typescript-CE_GQ-M1.js} +1 -1
  20. package/dist/frontend/assets/{xml-fE5sGZ5z.js → xml-CGjMtNcA.js} +1 -1
  21. package/dist/frontend/assets/{yaml-CZMoG4WG.js → yaml-Zju9kuFB.js} +1 -1
  22. package/dist/frontend/index.html +2 -2
  23. package/dist/index.js +3 -0
  24. package/dist/types/cronjob.js +2 -0
  25. package/dist/utils/chat-prompt-context.js +65 -0
  26. package/dist/utils/chat-session.js +12 -0
  27. package/dist/utils/index.js +27 -0
  28. package/dist/utils/workflow-interruption.js +5 -2
  29. package/dist/utils/workflow-run-validation.js +25 -0
  30. package/dist/utils/workspace-sync.js +17 -0
  31. package/dist/workflows/index.js +24 -25
  32. package/dist/workspace_files/.opencode/plugins/apply-patch-session-diff.ts +2 -2
  33. package/dist/workspace_files/.opencode/plugins/askCronjobUser.ts +106 -0
  34. package/dist/workspace_files/.opencode/plugins/manageCronjobTodos.ts +190 -0
  35. package/dist/workspace_files/.opencode/plugins/manageCronjobs.ts +376 -0
  36. package/dist/workspace_files/.opencode/plugins/markCronjobCompleted.ts +107 -0
  37. package/dist/workspace_files/.opencode/plugins/markCronjobFailed.ts +107 -0
  38. package/dist/workspace_files/AGENTS.md +51 -1
  39. package/package.json +1 -1
  40. package/prisma/generated/client/edge.js +50 -3
  41. package/prisma/generated/client/index-browser.js +47 -0
  42. package/prisma/generated/client/index.d.ts +13918 -7530
  43. package/prisma/generated/client/index.js +50 -3
  44. package/prisma/generated/client/package.json +1 -1
  45. package/prisma/generated/client/schema.prisma +72 -1
  46. package/prisma/generated/client/wasm.js +50 -3
  47. package/prisma/migrations/20260508050030_add_cronjobs/migration.sql +78 -0
  48. package/prisma/migrations/20260508093158_add_structured_cronjob_schedules/migration.sql +23 -0
  49. package/prisma/migrations/20260508105129_add_cronjob_targets/migration.sql +50 -0
  50. package/prisma/migrations/20260509044545_flatten_cronjob_schema/migration.sql +88 -0
  51. package/prisma/migrations/20260509052232_simplify_cronjob_schedule_storage/migration.sql +42 -0
  52. package/prisma/migrations/20260509054000_remove_chat_session_source_add_cronjob_run_indexes/migration.sql +28 -0
  53. package/prisma/migrations/20260509061000_cascade_cronjob_run_links/migration.sql +29 -0
  54. package/prisma/migrations/20260513073541_add_cronjob_run_todos/migration.sql +18 -0
  55. package/prisma/migrations/20260513133021_add_cronjob_user_response_wait/migration.sql +29 -0
  56. package/prisma/migrations/20260513135733_add_cronjob_user_handoff_state/migration.sql +30 -0
  57. package/prisma/migrations/20260513142511_drop_awaiting_user_response/migration.sql +35 -0
  58. package/prisma/migrations/20260514032204_simplify_cronjob_run_lifecycle/migration.sql +34 -0
  59. package/prisma/migrations/20260514043000_clear_cronjob_run_history/migration.sql +6 -0
  60. package/prisma/migrations/20260515094618_add_todo_list_version/migration.sql +32 -0
  61. package/prisma/migrations/20260516082714_add_cronjob_monitor_timeout/migration.sql +38 -0
  62. package/prisma/migrations/20260516083452_allow_decimal_cronjob_timeout/migration.sql +37 -0
  63. package/prisma/migrations/20260516084455_add_cronjob_timeout_defaults/migration.sql +31 -0
  64. package/prisma/schema.prisma +71 -1
  65. package/dist/frontend/assets/index-D1Hcz_bo.css +0 -1
@@ -0,0 +1,936 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.validateCronjobMonitorTimeout = validateCronjobMonitorTimeout;
7
+ exports.validateCronjobTarget = validateCronjobTarget;
8
+ exports.validateCronjobSchedule = validateCronjobSchedule;
9
+ exports.getNextRunAt = getNextRunAt;
10
+ exports.resumeCronjobRun = resumeCronjobRun;
11
+ exports.interruptCronjobRun = interruptCronjobRun;
12
+ exports.terminateCronjobRun = terminateCronjobRun;
13
+ exports.dispatchCronjobRun = dispatchCronjobRun;
14
+ exports.scheduleOneCronjob = scheduleOneCronjob;
15
+ exports.startUserCronjobScheduler = startUserCronjobScheduler;
16
+ exports.completeCurrentCronjobRun = completeCurrentCronjobRun;
17
+ const cron_1 = require("cron");
18
+ const crypto_1 = require("crypto");
19
+ const client_1 = __importDefault(require("../prisma/client"));
20
+ const client_2 = require("../../prisma/generated/client");
21
+ const opencode_client_1 = require("../utils/opencode-client");
22
+ const workspace_sync_1 = require("../utils/workspace-sync");
23
+ const workflow_runner_1 = require("../utils/workflow-runner");
24
+ const chat_session_1 = require("../utils/chat-session");
25
+ const chat_prompt_context_1 = require("../utils/chat-prompt-context");
26
+ const workflow_run_validation_1 = require("../utils/workflow-run-validation");
27
+ const session_abort_1 = require("../utils/session-abort");
28
+ const workflow_interruption_1 = require("../utils/workflow-interruption");
29
+ const CRONJOB_MONITOR_INTERVAL_MS = 5000;
30
+ const scheduledJobs = new Map();
31
+ const runningMonitors = new Map();
32
+ function nowMs() {
33
+ return BigInt(Date.now());
34
+ }
35
+ function throwCronjobAlreadyActive() {
36
+ throw {
37
+ status: 409,
38
+ message: "Cronjob already has an active run. Wait for it to finish, resume it, or terminate it first."
39
+ };
40
+ }
41
+ function isRunningRunUniquenessError(err) {
42
+ return err instanceof client_2.Prisma.PrismaClientKnownRequestError && err.code === "P2002";
43
+ }
44
+ function throwIfRunningRunUniquenessError(err) {
45
+ if (isRunningRunUniquenessError(err)) {
46
+ throwCronjobAlreadyActive();
47
+ }
48
+ throw err;
49
+ }
50
+ async function abortOpencodeSessionBestEffort(opencodeSessionId) {
51
+ try {
52
+ await (0, session_abort_1.abortOpencodeSession)(opencodeSessionId);
53
+ }
54
+ catch (err) {
55
+ console.error("Failed to abort OpenCode session after cronjob state transition:", err);
56
+ }
57
+ }
58
+ async function markWorkflowSessionAbortedBestEffort(sessionId) {
59
+ try {
60
+ await (0, workflow_interruption_1.markWorkflowSessionAborted)(sessionId);
61
+ }
62
+ catch (err) {
63
+ console.error("Failed to abort workflow session after cronjob state transition:", err);
64
+ }
65
+ }
66
+ function assertTimezone(timezone) {
67
+ try {
68
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
69
+ }
70
+ catch {
71
+ throw {
72
+ status: 400,
73
+ message: "timezone must be a valid IANA timezone"
74
+ };
75
+ }
76
+ }
77
+ function toCronPackageExpression(cronExpression) {
78
+ const parts = cronExpression.trim().split(/\s+/);
79
+ if (parts.length === 5) {
80
+ return `0 ${cronExpression.trim()}`;
81
+ }
82
+ if (parts.length === 6) {
83
+ return cronExpression.trim();
84
+ }
85
+ throw {
86
+ status: 400,
87
+ message: "cron_expression must have 5 or 6 fields"
88
+ };
89
+ }
90
+ function assertCronExpression(cronExpression, timezone) {
91
+ const expression = toCronPackageExpression(cronExpression);
92
+ const validation = cron_1.CronTime.validateCronExpression(expression);
93
+ if (!validation.valid) {
94
+ throw {
95
+ status: 400,
96
+ message: validation.error?.message || "Invalid cron expression"
97
+ };
98
+ }
99
+ new cron_1.CronTime(expression, timezone);
100
+ }
101
+ function assertMonitorTimeoutUnit(value) {
102
+ if (value !== "minutes" && value !== "hours" && value !== "days") {
103
+ throw {
104
+ status: 400,
105
+ message: "monitor_timeout_unit must be minutes, hours, or days"
106
+ };
107
+ }
108
+ return value;
109
+ }
110
+ function assertMonitorTimeoutValue(value) {
111
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
112
+ throw {
113
+ status: 400,
114
+ message: "monitor_timeout_value must be a non-negative number"
115
+ };
116
+ }
117
+ return value;
118
+ }
119
+ function validateCronjobMonitorTimeout(input) {
120
+ return {
121
+ monitorTimeoutValue: input.monitor_timeout_value === undefined ? 2 : assertMonitorTimeoutValue(input.monitor_timeout_value),
122
+ monitorTimeoutUnit: input.monitor_timeout_unit === undefined ? "hours" : assertMonitorTimeoutUnit(input.monitor_timeout_unit),
123
+ };
124
+ }
125
+ function monitorTimeoutToMs(value, unit) {
126
+ if (unit === "minutes")
127
+ return value * 60000;
128
+ if (unit === "hours")
129
+ return value * 3600000;
130
+ return value * 86400000;
131
+ }
132
+ function assertNonEmptyString(value, label) {
133
+ if (typeof value !== "string" || value.trim().length === 0) {
134
+ throw {
135
+ status: 400,
136
+ message: `${label} is required`
137
+ };
138
+ }
139
+ return value.trim();
140
+ }
141
+ function assertObject(value, label) {
142
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
143
+ throw {
144
+ status: 400,
145
+ message: `${label} must be an object`
146
+ };
147
+ }
148
+ return value;
149
+ }
150
+ function assertCronjobTargetType(value) {
151
+ if (value !== "prompt" && value !== "workflow") {
152
+ throw {
153
+ status: 400,
154
+ message: "target_type must be prompt or workflow"
155
+ };
156
+ }
157
+ return value;
158
+ }
159
+ async function validatePromptCronjobTarget(input) {
160
+ return {
161
+ prompt: assertNonEmptyString(input.prompt, "prompt"),
162
+ promptAllowWorkflowRunsWithoutPermission: input.allow_workflow_runs_without_permission !== false,
163
+ };
164
+ }
165
+ async function validateCronjobTarget(input, userId) {
166
+ const targetType = assertCronjobTargetType(input.target_type);
167
+ if (targetType === "prompt") {
168
+ const promptTarget = await validatePromptCronjobTarget(input);
169
+ return {
170
+ targetType,
171
+ prompt: promptTarget.prompt,
172
+ promptAllowWorkflowRunsWithoutPermission: promptTarget.promptAllowWorkflowRunsWithoutPermission,
173
+ workflowSlug: null,
174
+ workflowInputJson: null,
175
+ };
176
+ }
177
+ const workflowSlug = assertNonEmptyString(input.workflow_slug, "workflow_slug");
178
+ const workflowInputs = input.workflow_inputs === undefined ? {} : assertObject(input.workflow_inputs, "workflow_inputs");
179
+ await (0, workflow_run_validation_1.assertUserCanRunWorkflow)(workflowSlug, userId);
180
+ return {
181
+ targetType,
182
+ prompt: null,
183
+ promptAllowWorkflowRunsWithoutPermission: null,
184
+ workflowSlug,
185
+ workflowInputJson: JSON.stringify(workflowInputs),
186
+ };
187
+ }
188
+ function validateCronjobSchedule(input) {
189
+ if (typeof input.timezone !== "string" || input.timezone.trim().length === 0) {
190
+ throw {
191
+ status: 400,
192
+ message: "timezone is required"
193
+ };
194
+ }
195
+ const timezone = input.timezone.trim();
196
+ assertTimezone(timezone);
197
+ if (typeof input.cron_expression !== "string" || input.cron_expression.trim().length === 0) {
198
+ throw {
199
+ status: 400,
200
+ message: "cron_expression is required"
201
+ };
202
+ }
203
+ const cronExpression = input.cron_expression.trim();
204
+ assertCronExpression(cronExpression, timezone);
205
+ return {
206
+ cronExpression,
207
+ timezone,
208
+ };
209
+ }
210
+ function getNextRunAt(schedule) {
211
+ const expression = toCronPackageExpression(schedule.cron_expression);
212
+ const cronTime = new cron_1.CronTime(expression, schedule.timezone);
213
+ return cronTime.sendAt().toMillis();
214
+ }
215
+ function getCronjobTimeoutAt(timeoutValue, timeoutUnit, startedAtMs) {
216
+ return startedAtMs + monitorTimeoutToMs(timeoutValue, timeoutUnit);
217
+ }
218
+ async function buildCronjobPrompt(args) {
219
+ const sections = [
220
+ "# Cronjob runtime instructions",
221
+ "",
222
+ "This is an unattended scheduled TeamCopilot cronjob run.",
223
+ "Treat the cronjob prompt below as the task to execute.",
224
+ "First thing you must do is understand the task. If it refers to skills / workflows, read them first. Then call getCronjobTodos to fetch the current todo_list_version, and based on the instructions from the task and the files you read, call addCronjobTodos with a granular todo list. Do not start executing the task until TeamCopilot gives you the first current todo item.",
225
+ "The todo list is editable by you. Use getCurrentCronjobTodo to inspect the current todo (returns up to one item with its id) and getCronjobTodos to inspect the active todo list (returns all active todo ids, contents, and a todo_list_version snapshot token).",
226
+ "Use addCronjobTodos to insert new todo items anywhere in the active todo list, and always pass the todo_list_version returned by the most recent getCronjobTodos call. Use clearCronjobTodos to remove one or more active todo items from the list by todo id.",
227
+ "After planning, TeamCopilot will give you exactly one current todo item at a time in this same session.",
228
+ "When working on a current todo item, work only on that item. If you discover more required work, call addCronjobTodos or clearCronjobTodos as needed. Refer to todos by id, not by position.",
229
+ "When the current todo item is complete, call finishCurrentCronjobTodo with a concise completion summary and then stop. TeamCopilot will give you the next todo item.",
230
+ "Keep working until every todo item needed for the requested cronjob task is complete or the loop is blocked by a real permission, tool question, or safety boundary.",
231
+ "Do not ask the user questions unless the task explicitly requires user approval or clarification that cannot be safely inferred.",
232
+ "If you need to ask the user for input or notify them that the cronjob needs their attention, call askCronjobUser with the message. This reveals the hidden cronjob chat to the user and pauses the auto-continue loop until the user explicitly resumes the cronjob.",
233
+ "If the task cannot be finished because of a non-recoverable issue, call markCronjobFailed with a concise reason instead of leaving the run hanging.",
234
+ "The only way to mark this cronjob finished successfully is to call the markCronjobCompleted tool.",
235
+ "markCronjobCompleted will fail until all TeamCopilot cronjob todos have been finished.",
236
+ "Call markCronjobCompleted only after the requested work is 100% complete.",
237
+ "The completion summary must be concise and suitable for cronjob run history.",
238
+ ];
239
+ sections.push("", (0, chat_prompt_context_1.buildCurrentTimePrompt)());
240
+ const availableSkillsPrompt = await (0, chat_prompt_context_1.buildAvailableSkillsPrompt)(args.userId);
241
+ const availableSecretsPrompt = await (0, chat_prompt_context_1.buildAvailableSecretsPrompt)(args.userId);
242
+ if (availableSkillsPrompt)
243
+ sections.push("", availableSkillsPrompt);
244
+ if (availableSecretsPrompt)
245
+ sections.push("", availableSecretsPrompt);
246
+ sections.push("", chat_prompt_context_1.ACTUAL_USER_MESSAGE_MARKER, "", "# Cronjob task", "", `Name: ${args.cronjobName}`, "", args.cronjobPrompt);
247
+ sections.push("");
248
+ sections.push("Current task: Understand the task requirements (based on the above task (read skill files / workflows if needed), and create a granular todo list with addCronjobTodos. Then stop - only start the first todo once the system prompts you with the todo item.");
249
+ return sections.join("\n");
250
+ }
251
+ function parseWorkflowInputJson(value) {
252
+ if (value === null || value.trim().length === 0)
253
+ return {};
254
+ const parsed = JSON.parse(value);
255
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
256
+ throw new Error("Workflow input JSON must be an object");
257
+ }
258
+ return parsed;
259
+ }
260
+ function getErrorMessage(err, fallback) {
261
+ return err instanceof Error ? err.message : fallback;
262
+ }
263
+ async function markCronjobRunFailed(runId, errorMessage) {
264
+ await client_1.default.cronjob_runs.updateMany({
265
+ where: { id: runId, status: "running" },
266
+ data: {
267
+ status: "failed",
268
+ completed_at: nowMs(),
269
+ error_message: errorMessage,
270
+ },
271
+ });
272
+ }
273
+ async function createSkippedCronjobRun(cronjobId) {
274
+ const now = nowMs();
275
+ const skipped = await client_1.default.cronjob_runs.create({
276
+ data: {
277
+ cronjob_id: cronjobId,
278
+ status: "skipped",
279
+ started_at: now,
280
+ completed_at: now,
281
+ error_message: "Previous run is still active.",
282
+ },
283
+ });
284
+ return skipped.id;
285
+ }
286
+ async function finishWorkflowCronjobRun(cronjobRunId, completion) {
287
+ try {
288
+ const result = await completion;
289
+ await client_1.default.cronjob_runs.updateMany({
290
+ where: { id: cronjobRunId, status: "running" },
291
+ data: {
292
+ status: result.status === "success" ? "success" : "failed",
293
+ completed_at: nowMs(),
294
+ summary: result.status === "success" ? "Workflow completed successfully." : null,
295
+ error_message: result.status === "success" ? null : result.output.slice(-1000),
296
+ },
297
+ });
298
+ }
299
+ catch (err) {
300
+ await markCronjobRunFailed(cronjobRunId, getErrorMessage(err, "Workflow cronjob failed."));
301
+ }
302
+ }
303
+ async function revealRunForUserInput(runId) {
304
+ const updatedAt = Number(nowMs());
305
+ const run = await client_1.default.cronjob_runs.findUnique({
306
+ where: { id: runId },
307
+ select: { session_id: true },
308
+ });
309
+ if (!run?.session_id)
310
+ return;
311
+ await client_1.default.chat_sessions.update({
312
+ where: { id: run.session_id },
313
+ data: {
314
+ visible_to_user: true,
315
+ updated_at: updatedAt,
316
+ },
317
+ });
318
+ }
319
+ async function cronjobSessionHasPendingUserInput(opencodeSessionId) {
320
+ const pendingQuestions = await (0, opencode_client_1.listPendingQuestions)();
321
+ if (pendingQuestions.some((question) => question.sessionID === opencodeSessionId)) {
322
+ return true;
323
+ }
324
+ const pendingPermissions = await (0, opencode_client_1.listPendingPermissions)();
325
+ if (pendingPermissions.some((permission) => permission.sessionID === opencodeSessionId)) {
326
+ return true;
327
+ }
328
+ const customPendingPermission = await client_1.default.tool_execution_permissions.findFirst({
329
+ where: {
330
+ opencode_session_id: opencodeSessionId,
331
+ status: "pending",
332
+ },
333
+ select: { id: true },
334
+ });
335
+ return customPendingPermission !== null;
336
+ }
337
+ async function getCurrentCronjobTodo(runId) {
338
+ return client_1.default.cronjob_run_todos.findFirst({
339
+ where: { run_id: runId, status: "in_progress" },
340
+ orderBy: { position: "asc" },
341
+ select: { id: true, content: true, position: true },
342
+ });
343
+ }
344
+ async function getNextPendingCronjobTodo(runId) {
345
+ return client_1.default.cronjob_run_todos.findFirst({
346
+ where: { run_id: runId, status: "pending" },
347
+ orderBy: { position: "asc" },
348
+ select: { id: true, content: true, position: true },
349
+ });
350
+ }
351
+ async function getCronjobTodoCount(runId) {
352
+ return client_1.default.cronjob_run_todos.count({ where: { run_id: runId } });
353
+ }
354
+ async function startCronjobTodo(runId, todoId) {
355
+ return client_1.default.$transaction(async (tx) => {
356
+ const todo = await tx.cronjob_run_todos.update({
357
+ where: { id: todoId },
358
+ data: { status: "in_progress" },
359
+ select: { id: true, content: true, position: true },
360
+ });
361
+ await tx.cronjob_runs.update({
362
+ where: { id: runId },
363
+ data: { todo_list_version: { increment: 1 } },
364
+ });
365
+ return todo;
366
+ });
367
+ }
368
+ function buildCronjobPlanningReminderPrompt() {
369
+ return [
370
+ "# Cronjob planning required",
371
+ "",
372
+ "You must create the TeamCopilot cronjob todo list before doing any task work.",
373
+ "Call getCronjobTodos first to fetch the current todo_list_version, then call addCronjobTodos with granular todo items that cover the requested cronjob task.",
374
+ "Do not execute the task yet. TeamCopilot will give you the first current todo item after the todo list is saved."
375
+ ].join("\n");
376
+ }
377
+ function buildCronjobCurrentTodoPrompt(todo) {
378
+ return [
379
+ "# Current cronjob todo",
380
+ "",
381
+ `Todo ${todo.position + 1}: ${todo.content}`,
382
+ "",
383
+ "Work only on this todo item.",
384
+ "If you discover additional required work, call getCronjobTodos first if you need a fresh todo snapshot, then call addCronjobTodos with the returned todo_list_version.",
385
+ "If you want to inspect the current todo again, use getCurrentCronjobTodo.",
386
+ "When this todo item is fully complete, call finishCurrentCronjobTodo with a concise completion summary, then stop.",
387
+ "Do not work on later todo items until TeamCopilot gives them to you.",
388
+ "Do not call markCronjobCompleted while a current todo item is active."
389
+ ].join("\n");
390
+ }
391
+ function buildCronjobCurrentTodoContinuationPrompt(todo, iteration) {
392
+ return [
393
+ "# Cronjob continuation",
394
+ "",
395
+ `The cronjob monitor found this session idle while the current todo is still active. This is continuation attempt ${iteration}.`,
396
+ "",
397
+ `Current todo: ${todo.content}`,
398
+ "",
399
+ "Continue this todo item only.",
400
+ "If this todo item is complete, call finishCurrentCronjobTodo with a concise completion summary, then stop.",
401
+ "If more work is required, take the next concrete step for this todo item.",
402
+ "If you discover additional required work, call getCronjobTodos first if you need a fresh todo snapshot, then call addCronjobTodos or clearCronjobTodos.",
403
+ "If the task cannot continue, call markCronjobFailed.",
404
+ "If you truly need user input that cannot be safely inferred, call askCronjobUser with the message for the user.",
405
+ "",
406
+ "Do not switch to another todo item."
407
+ ].join("\n");
408
+ }
409
+ function buildCronjobFinalReviewPrompt() {
410
+ return [
411
+ "# Cronjob final review",
412
+ "",
413
+ "All TeamCopilot cronjob todos are marked complete.",
414
+ "Review the original cronjob task and the work completed in this session.",
415
+ "If the requested task is 100% complete, call markCronjobCompleted with a concise run-history summary.",
416
+ "If required work is still missing, call getCronjobTodos first if you need a fresh todo snapshot, then call addCronjobTodos with the new todo items.",
417
+ "If the task cannot be completed, call markCronjobFailed with a concise reason.",
418
+ "If you truly need user input that cannot be safely inferred, call askCronjobUser with the message for the user."
419
+ ].join("\n");
420
+ }
421
+ async function monitorCronjobRun(runId, opencodeSessionId, timeoutAtMs) {
422
+ if (runningMonitors.has(runId))
423
+ return;
424
+ runningMonitors.set(runId, null);
425
+ let revealedForUserInput = false;
426
+ let isChecking = false;
427
+ let continuationCount = 0;
428
+ let interval;
429
+ const check = async () => {
430
+ if (isChecking) {
431
+ return;
432
+ }
433
+ isChecking = true;
434
+ try {
435
+ const run = await client_1.default.cronjob_runs.findUnique({
436
+ where: { id: runId },
437
+ select: { status: true }
438
+ });
439
+ if (!run || run.status !== "running") {
440
+ clearInterval(interval);
441
+ runningMonitors.delete(runId);
442
+ return;
443
+ }
444
+ if (Date.now() >= timeoutAtMs) {
445
+ await markCronjobRunFailed(runId, "Cronjob run timed out after the configured monitor timeout.");
446
+ await abortOpencodeSessionBestEffort(opencodeSessionId);
447
+ clearInterval(interval);
448
+ runningMonitors.delete(runId);
449
+ return;
450
+ }
451
+ if (await cronjobSessionHasPendingUserInput(opencodeSessionId)) {
452
+ if (!revealedForUserInput) {
453
+ await revealRunForUserInput(runId);
454
+ revealedForUserInput = true;
455
+ }
456
+ return;
457
+ }
458
+ const client = await (0, opencode_client_1.getOpencodeClient)();
459
+ const statusResult = await client.session.status();
460
+ if (statusResult.error) {
461
+ await markCronjobRunFailed(runId, getErrorMessage(statusResult.error, "Failed to get cronjob session status."));
462
+ clearInterval(interval);
463
+ runningMonitors.delete(runId);
464
+ return;
465
+ }
466
+ const sessionStatusType = (0, chat_session_1.getSessionStatusTypeForSession)(statusResult.data, opencodeSessionId);
467
+ if (sessionStatusType !== "idle")
468
+ return;
469
+ const todoCount = await getCronjobTodoCount(runId);
470
+ if (todoCount === 0) {
471
+ const planningResult = await client.session.promptAsync({
472
+ path: { id: opencodeSessionId },
473
+ body: {
474
+ parts: [{ type: "text", text: buildCronjobPlanningReminderPrompt() }],
475
+ },
476
+ });
477
+ if (planningResult.error) {
478
+ await markCronjobRunFailed(runId, getErrorMessage(planningResult.error, "Failed to prompt cronjob todo planning."));
479
+ clearInterval(interval);
480
+ runningMonitors.delete(runId);
481
+ }
482
+ return;
483
+ }
484
+ const currentTodo = await getCurrentCronjobTodo(runId);
485
+ if (currentTodo) {
486
+ continuationCount += 1;
487
+ const continueResult = await client.session.promptAsync({
488
+ path: { id: opencodeSessionId },
489
+ body: {
490
+ parts: [{ type: "text", text: buildCronjobCurrentTodoContinuationPrompt(currentTodo, continuationCount) }],
491
+ },
492
+ });
493
+ if (continueResult.error) {
494
+ await markCronjobRunFailed(runId, getErrorMessage(continueResult.error, "Failed to continue idle cronjob todo."));
495
+ clearInterval(interval);
496
+ runningMonitors.delete(runId);
497
+ }
498
+ return;
499
+ }
500
+ const pendingTodo = await getNextPendingCronjobTodo(runId);
501
+ if (pendingTodo) {
502
+ continuationCount = 0;
503
+ const startedTodo = await startCronjobTodo(runId, pendingTodo.id);
504
+ const todoResult = await client.session.promptAsync({
505
+ path: { id: opencodeSessionId },
506
+ body: {
507
+ parts: [{ type: "text", text: buildCronjobCurrentTodoPrompt(startedTodo) }],
508
+ },
509
+ });
510
+ if (todoResult.error) {
511
+ await markCronjobRunFailed(runId, getErrorMessage(todoResult.error, "Failed to prompt next cronjob todo."));
512
+ clearInterval(interval);
513
+ runningMonitors.delete(runId);
514
+ }
515
+ return;
516
+ }
517
+ const continueResult = await client.session.promptAsync({
518
+ path: { id: opencodeSessionId },
519
+ body: {
520
+ parts: [{ type: "text", text: buildCronjobFinalReviewPrompt() }],
521
+ },
522
+ });
523
+ if (continueResult.error) {
524
+ await markCronjobRunFailed(runId, getErrorMessage(continueResult.error, "Failed to prompt cronjob final review."));
525
+ clearInterval(interval);
526
+ runningMonitors.delete(runId);
527
+ }
528
+ }
529
+ catch (err) {
530
+ await markCronjobRunFailed(runId, getErrorMessage(err, "Failed to monitor cronjob run."));
531
+ console.error("Failed to monitor cronjob run:", err);
532
+ clearInterval(interval);
533
+ runningMonitors.delete(runId);
534
+ }
535
+ finally {
536
+ isChecking = false;
537
+ }
538
+ };
539
+ interval = setInterval(() => {
540
+ void check();
541
+ }, CRONJOB_MONITOR_INTERVAL_MS);
542
+ runningMonitors.set(runId, interval);
543
+ }
544
+ async function resumeCronjobRun(runId) {
545
+ const run = await client_1.default.cronjob_runs.findUnique({
546
+ where: { id: runId },
547
+ include: {
548
+ cronjob: {
549
+ select: {
550
+ target_type: true,
551
+ monitor_timeout_value: true,
552
+ monitor_timeout_unit: true,
553
+ },
554
+ },
555
+ },
556
+ });
557
+ if (!run) {
558
+ throw {
559
+ status: 404,
560
+ message: "Cronjob run not found"
561
+ };
562
+ }
563
+ if (run.cronjob.target_type !== "prompt" || !run.opencode_session_id || !run.session_id) {
564
+ throw {
565
+ status: 400,
566
+ message: "Only prompt cronjob chats can be resumed."
567
+ };
568
+ }
569
+ if (run.status !== "paused") {
570
+ throw {
571
+ status: 400,
572
+ message: `Only paused prompt cronjob runs can be resumed. Current status is: ${run.status}`
573
+ };
574
+ }
575
+ const opencodeSessionId = run.opencode_session_id;
576
+ const sessionId = run.session_id;
577
+ try {
578
+ await client_1.default.$transaction(async (tx) => {
579
+ const resumedRun = await tx.cronjob_runs.updateMany({
580
+ where: { id: run.id, status: "paused" },
581
+ data: {
582
+ status: "running",
583
+ completed_at: null,
584
+ error_message: null,
585
+ summary: null,
586
+ },
587
+ });
588
+ if (resumedRun.count !== 1) {
589
+ throw {
590
+ status: 409,
591
+ message: "Cronjob run is no longer paused."
592
+ };
593
+ }
594
+ await tx.chat_sessions.update({
595
+ where: { id: sessionId },
596
+ data: { updated_at: Number(nowMs()) },
597
+ });
598
+ });
599
+ }
600
+ catch (err) {
601
+ throwIfRunningRunUniquenessError(err);
602
+ }
603
+ await monitorCronjobRun(run.id, opencodeSessionId, getCronjobTimeoutAt(run.cronjob.monitor_timeout_value, assertMonitorTimeoutUnit(run.cronjob.monitor_timeout_unit), Number(run.started_at)));
604
+ }
605
+ async function recoverRunningPromptCronjobRun(runId) {
606
+ const run = await client_1.default.cronjob_runs.findUnique({
607
+ where: { id: runId },
608
+ include: {
609
+ cronjob: {
610
+ select: {
611
+ target_type: true,
612
+ monitor_timeout_value: true,
613
+ monitor_timeout_unit: true,
614
+ },
615
+ },
616
+ },
617
+ });
618
+ if (!run || run.status !== "running" || run.cronjob.target_type !== "prompt" || !run.opencode_session_id)
619
+ return;
620
+ await monitorCronjobRun(run.id, run.opencode_session_id, getCronjobTimeoutAt(run.cronjob.monitor_timeout_value, assertMonitorTimeoutUnit(run.cronjob.monitor_timeout_unit), Number(run.started_at)));
621
+ }
622
+ async function interruptCronjobRun(runId) {
623
+ const run = await client_1.default.cronjob_runs.findUnique({
624
+ where: { id: runId },
625
+ include: {
626
+ cronjob: { select: { target_type: true } },
627
+ },
628
+ });
629
+ if (!run) {
630
+ throw { status: 404, message: "Cronjob run not found" };
631
+ }
632
+ if (run.cronjob.target_type !== "prompt" || !run.opencode_session_id || !run.session_id) {
633
+ throw { status: 400, message: "Only prompt cronjob runs can be interrupted." };
634
+ }
635
+ if (run.status !== "running") {
636
+ throw { status: 400, message: `Only running cronjob runs can be interrupted. Current status is: ${run.status}` };
637
+ }
638
+ const sessionId = run.session_id;
639
+ const opencodeSessionId = run.opencode_session_id;
640
+ await client_1.default.$transaction(async (tx) => {
641
+ const interruptedRun = await tx.cronjob_runs.updateMany({
642
+ where: { id: run.id, status: "running" },
643
+ data: { status: "paused" },
644
+ });
645
+ if (interruptedRun.count !== 1) {
646
+ throw { status: 409, message: "Cronjob run is no longer running." };
647
+ }
648
+ await tx.chat_sessions.update({
649
+ where: { id: sessionId },
650
+ data: {
651
+ visible_to_user: true,
652
+ updated_at: Number(nowMs()),
653
+ },
654
+ });
655
+ });
656
+ await abortOpencodeSessionBestEffort(opencodeSessionId);
657
+ }
658
+ async function terminateCronjobRun(runId) {
659
+ const run = await client_1.default.cronjob_runs.findUnique({
660
+ where: { id: runId },
661
+ include: {
662
+ cronjob: { select: { target_type: true } },
663
+ },
664
+ });
665
+ if (!run) {
666
+ throw { status: 404, message: "Cronjob run not found" };
667
+ }
668
+ if (!["running", "paused"].includes(run.status)) {
669
+ return;
670
+ }
671
+ const terminatedRun = await client_1.default.cronjob_runs.updateMany({
672
+ where: {
673
+ id: run.id,
674
+ status: { in: ["running", "paused"] },
675
+ },
676
+ data: {
677
+ status: run.cronjob.target_type === "prompt" ? "terminated" : "failed",
678
+ completed_at: nowMs(),
679
+ error_message: "Cronjob run was terminated by the user.",
680
+ },
681
+ });
682
+ if (terminatedRun.count !== 1) {
683
+ return;
684
+ }
685
+ if (run.cronjob.target_type === "prompt") {
686
+ if (run.status === "running") {
687
+ await abortOpencodeSessionBestEffort(run.opencode_session_id);
688
+ }
689
+ return;
690
+ }
691
+ const workflowRun = await client_1.default.workflow_runs.findUnique({
692
+ where: { id: run.workflow_run_id },
693
+ select: { session_id: true },
694
+ });
695
+ if (workflowRun?.session_id) {
696
+ await markWorkflowSessionAbortedBestEffort(workflowRun.session_id);
697
+ }
698
+ }
699
+ async function dispatchCronjobRun(cronjobId, mode = "scheduled") {
700
+ const cronjob = await client_1.default.cronjobs.findUnique({
701
+ where: { id: cronjobId },
702
+ include: { user: true },
703
+ });
704
+ if (!cronjob) {
705
+ throw new Error("Cronjob not found");
706
+ }
707
+ if (!cronjob.enabled && mode === "scheduled") {
708
+ throw new Error("Cronjob is disabled");
709
+ }
710
+ const activeRun = await client_1.default.cronjob_runs.findFirst({
711
+ where: {
712
+ cronjob_id: cronjob.id,
713
+ status: { in: ["running", "paused"] },
714
+ },
715
+ select: { id: true }
716
+ });
717
+ if (activeRun) {
718
+ if (mode === "scheduled") {
719
+ return await createSkippedCronjobRun(cronjob.id);
720
+ }
721
+ throwCronjobAlreadyActive();
722
+ }
723
+ if (cronjob.target_type === "workflow") {
724
+ const now = nowMs();
725
+ let run;
726
+ try {
727
+ run = await client_1.default.cronjob_runs.create({
728
+ data: {
729
+ cronjob_id: cronjob.id,
730
+ status: "running",
731
+ started_at: now,
732
+ },
733
+ select: { id: true },
734
+ });
735
+ }
736
+ catch (err) {
737
+ if (isRunningRunUniquenessError(err) && mode === "scheduled") {
738
+ return await createSkippedCronjobRun(cronjob.id);
739
+ }
740
+ throwIfRunningRunUniquenessError(err);
741
+ }
742
+ try {
743
+ const workflowSlug = assertNonEmptyString(cronjob.workflow_slug, "workflow_slug");
744
+ const workflowInputs = parseWorkflowInputJson(cronjob.workflow_input_json);
745
+ await (0, workflow_run_validation_1.assertUserCanRunWorkflow)(workflowSlug, cronjob.user_id);
746
+ const startedRun = await (0, workflow_runner_1.startWorkflowRunViaBackend)({
747
+ workspaceDir: (0, workspace_sync_1.getWorkspaceDirFromEnv)(),
748
+ slug: workflowSlug,
749
+ inputs: workflowInputs,
750
+ authUserId: cronjob.user_id,
751
+ sessionId: `cronjob-${run.id}`,
752
+ messageId: `cronjob-message-${(0, crypto_1.randomUUID)()}`,
753
+ callId: `cronjob-call-${(0, crypto_1.randomUUID)()}`,
754
+ requirePermissionPrompt: false,
755
+ secretResolutionMode: "user",
756
+ runSource: "cronjob",
757
+ });
758
+ await client_1.default.cronjob_runs.update({
759
+ where: { id: run.id },
760
+ data: { workflow_run_id: startedRun.runId },
761
+ });
762
+ void finishWorkflowCronjobRun(run.id, startedRun.completion);
763
+ return run.id;
764
+ }
765
+ catch (err) {
766
+ await markCronjobRunFailed(run.id, getErrorMessage(err, "Failed to start workflow cronjob."));
767
+ return run.id;
768
+ }
769
+ }
770
+ const userPrompt = cronjob.prompt;
771
+ if (!userPrompt) {
772
+ throw new Error("Prompt cronjob target is missing prompt");
773
+ }
774
+ const client = await (0, opencode_client_1.getOpencodeClient)();
775
+ const sessionResult = await client.session.create();
776
+ if (sessionResult.error || !sessionResult.data) {
777
+ throw new Error("Failed to create opencode session for cronjob");
778
+ }
779
+ const now = nowMs();
780
+ let run;
781
+ try {
782
+ run = await client_1.default.$transaction(async (tx) => {
783
+ const chatSession = await tx.chat_sessions.create({
784
+ data: {
785
+ user_id: cronjob.user_id,
786
+ opencode_session_id: sessionResult.data.id,
787
+ title: `Cronjob: ${cronjob.name}`,
788
+ visible_to_user: false,
789
+ created_at: Number(now),
790
+ updated_at: Number(now),
791
+ },
792
+ select: { id: true },
793
+ });
794
+ return await tx.cronjob_runs.create({
795
+ data: {
796
+ cronjob_id: cronjob.id,
797
+ status: "running",
798
+ started_at: now,
799
+ opencode_session_id: sessionResult.data.id,
800
+ session_id: chatSession.id,
801
+ },
802
+ select: { id: true },
803
+ });
804
+ });
805
+ }
806
+ catch (err) {
807
+ if (isRunningRunUniquenessError(err) && mode === "scheduled") {
808
+ return await createSkippedCronjobRun(cronjob.id);
809
+ }
810
+ throwIfRunningRunUniquenessError(err);
811
+ }
812
+ const runtimePrompt = await buildCronjobPrompt({
813
+ cronjobName: cronjob.name,
814
+ cronjobPrompt: userPrompt,
815
+ userId: cronjob.user_id,
816
+ });
817
+ const promptResult = await client.session.promptAsync({
818
+ path: { id: sessionResult.data.id },
819
+ body: {
820
+ parts: [{ type: "text", text: runtimePrompt }],
821
+ },
822
+ });
823
+ if (promptResult.error) {
824
+ await markCronjobRunFailed(run.id, "Failed to start cronjob opencode prompt.");
825
+ throw new Error("Failed to send cronjob prompt to opencode");
826
+ }
827
+ await monitorCronjobRun(run.id, sessionResult.data.id, getCronjobTimeoutAt(cronjob.monitor_timeout_value, assertMonitorTimeoutUnit(cronjob.monitor_timeout_unit), Number(now)));
828
+ return run.id;
829
+ }
830
+ function scheduleOneCronjob(cronjob) {
831
+ const existing = scheduledJobs.get(cronjob.id);
832
+ if (existing) {
833
+ void existing.stop();
834
+ scheduledJobs.delete(cronjob.id);
835
+ }
836
+ if (!cronjob.enabled)
837
+ return;
838
+ const schedule = cronjob;
839
+ const expression = toCronPackageExpression(schedule.cron_expression);
840
+ const job = new cron_1.CronJob(expression, () => {
841
+ void dispatchCronjobRun(cronjob.id, "scheduled").catch((err) => {
842
+ console.error(`Failed to dispatch cronjob ${cronjob.id}:`, err);
843
+ });
844
+ }, null, false, schedule.timezone);
845
+ job.start();
846
+ scheduledJobs.set(cronjob.id, job);
847
+ }
848
+ async function startUserCronjobScheduler() {
849
+ const recoverablePromptRuns = await client_1.default.cronjob_runs.findMany({
850
+ where: {
851
+ status: "running",
852
+ opencode_session_id: { not: null },
853
+ cronjob: { target_type: "prompt" },
854
+ },
855
+ select: { id: true },
856
+ });
857
+ for (const run of recoverablePromptRuns) {
858
+ await recoverRunningPromptCronjobRun(run.id);
859
+ }
860
+ const cronjobs = await client_1.default.cronjobs.findMany({
861
+ where: { enabled: true },
862
+ select: {
863
+ id: true,
864
+ cron_expression: true,
865
+ timezone: true,
866
+ enabled: true,
867
+ }
868
+ });
869
+ for (const cronjob of cronjobs) {
870
+ scheduleOneCronjob(cronjob);
871
+ }
872
+ }
873
+ async function completeCurrentCronjobRun(opencodeSessionId, summary) {
874
+ const runs = await client_1.default.cronjob_runs.findMany({
875
+ where: {
876
+ opencode_session_id: opencodeSessionId,
877
+ cronjob: { target_type: "prompt" },
878
+ },
879
+ });
880
+ if (runs.length === 0) {
881
+ throw {
882
+ status: 404,
883
+ message: "This is not a cronjob session. If this was called via the markCronjobCompleted tool, then do not use this tool again."
884
+ };
885
+ }
886
+ if (runs.length > 1) {
887
+ throw {
888
+ status: 500,
889
+ message: "Invariant violation: multiple prompt cronjob runs share one OpenCode session."
890
+ };
891
+ }
892
+ const run = runs[0];
893
+ if (!["running", "paused"].includes(run.status)) {
894
+ throw {
895
+ status: 404,
896
+ message: `Cronjob is not active. Current state is: ${run.status}`
897
+ };
898
+ }
899
+ const unfinishedTodo = await client_1.default.cronjob_run_todos.findFirst({
900
+ where: {
901
+ run_id: run.id,
902
+ status: { not: "completed" },
903
+ },
904
+ orderBy: { position: "asc" },
905
+ select: { content: true, status: true },
906
+ });
907
+ if (unfinishedTodo) {
908
+ throw {
909
+ status: 400,
910
+ message: `Cronjob still has an unfinished todo (${unfinishedTodo.status}): ${unfinishedTodo.content}`
911
+ };
912
+ }
913
+ const todoCount = await client_1.default.cronjob_run_todos.count({
914
+ where: { run_id: run.id },
915
+ });
916
+ if (todoCount === 0) {
917
+ throw {
918
+ status: 400,
919
+ message: "Cronjob cannot be marked complete before addCronjobTodos has created and finishCurrentCronjobTodo has completed the todo list."
920
+ };
921
+ }
922
+ const completedRun = await client_1.default.cronjob_runs.updateMany({
923
+ where: { id: run.id, status: { in: ["running", "paused"] } },
924
+ data: {
925
+ status: "success",
926
+ completed_at: nowMs(),
927
+ summary,
928
+ },
929
+ });
930
+ if (completedRun.count !== 1) {
931
+ throw {
932
+ status: 409,
933
+ message: "Cronjob run is no longer active."
934
+ };
935
+ }
936
+ }