import { z } from 'zod'; import { db } from '../db/client.js'; import { resolveAutomationType, resolveStoredAutomationTypeId } from './automation-type-config-service.js'; import { createBoardPost, receiveBoardPostAutomation } from './board-service.js'; import { buildManagedScheduleServiceMetadata, hasManagedScheduleServicePackage, prepareManagedScheduleServiceDirectory, removeManagedScheduleServiceArtifacts, runManagedScheduleService, } from './managed-schedule-service.js'; import { ensureSchedulePromptSnapshot, parseAutomationContextIds, stringifyAutomationContextIds, } from './automation-context-service.js'; import { createCompletedPlanExecutionLogItem, createPlanSourceWorkHistory, createPlanActionHistory, ensurePlanTable, planAutomationTypeSchema, PLAN_TABLE, } from './plan-service.js'; import { getKstNowParts } from './worklog-automation-utils.js'; export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks'; const scheduleModes = ['interval', 'daily'] as const; const repeatIntervalUnits = ['second', 'minute', 'hour', 'day', 'week', 'month'] as const; const scheduleExecutionModes = ['codex', 'managed-service'] as const; const DEFAULT_DAILY_RUN_TIME = '09:00'; const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; const MAX_REPEAT_INTERVAL_SECONDS = 31_536_000; const DEFAULT_REPEAT_INTERVAL_SECONDS = 60 * 60; export const createPlanScheduledTaskSchema = z.object({ workId: z.string().trim().optional().default('반복작업'), note: z.string().default(''), automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')), automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]), releaseTarget: z.string().trim().min(1).default('release'), jangsingProcessingRequired: z.boolean().default(true), autoDeployToMain: z.boolean().default(true), suppressWebPush: z.boolean().default(false), enabled: z.boolean().default(true), immediateRunEnabled: z.boolean().default(true), refreshContextSnapshotOnNextRun: z.boolean().default(false), executionMode: z.enum(scheduleExecutionModes).default('codex'), recreateManagedServiceOnNextSave: z.boolean().default(false), scheduleMode: z.enum(scheduleModes).default('interval'), repeatIntervalValue: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).default(60), repeatIntervalUnit: z.enum(repeatIntervalUnits).default('minute'), repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(), repeatIntervalSeconds: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), dailyRunTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default(DEFAULT_DAILY_RUN_TIME), repeatWindowStartTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), repeatWindowEndTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), }); export const updatePlanScheduledTaskSchema = z.object({ workId: z.string().trim().optional(), note: z.string().optional(), automationType: z.preprocess((value) => value, planAutomationTypeSchema.optional()), automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional(), releaseTarget: z.string().trim().min(1).optional(), jangsingProcessingRequired: z.boolean().optional(), autoDeployToMain: z.boolean().optional(), suppressWebPush: z.boolean().optional(), enabled: z.boolean().optional(), immediateRunEnabled: z.boolean().optional(), refreshContextSnapshotOnNextRun: z.boolean().optional(), executionMode: z.enum(scheduleExecutionModes).optional(), recreateManagedServiceOnNextSave: z.boolean().optional(), scheduleMode: z.enum(scheduleModes).optional(), repeatIntervalValue: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), repeatIntervalUnit: z.enum(repeatIntervalUnits).optional(), repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(), repeatIntervalSeconds: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), dailyRunTime: z.string().regex(TIME_OF_DAY_PATTERN).optional(), repeatWindowStartTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), repeatWindowEndTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), }); function normalizeScheduledWorkId(value?: string | null) { const workId = String(value ?? '').trim(); const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') { return '반복작업'; } return workId; } function normalizeRepeatIntervalMinutes(value: unknown) { const numericValue = Number(value); if (!Number.isFinite(numericValue)) { return 60; } return Math.min(525600, Math.max(1, Math.round(numericValue))); } function normalizeRepeatIntervalValue(value: unknown) { const numericValue = Number(value); if (!Number.isFinite(numericValue)) { return 60; } return Math.min(MAX_REPEAT_INTERVAL_SECONDS, Math.max(1, Math.round(numericValue))); } function normalizeRepeatIntervalSeconds(value: unknown) { const numericValue = Number(value); if (!Number.isFinite(numericValue)) { return DEFAULT_REPEAT_INTERVAL_SECONDS; } return Math.min(MAX_REPEAT_INTERVAL_SECONDS, Math.max(1, Math.round(numericValue))); } function normalizeRepeatIntervalUnit(value: unknown): (typeof repeatIntervalUnits)[number] { return repeatIntervalUnits.includes(value as (typeof repeatIntervalUnits)[number]) ? (value as (typeof repeatIntervalUnits)[number]) : 'minute'; } function normalizeScheduleMode(value: unknown): (typeof scheduleModes)[number] { return scheduleModes.includes(value as (typeof scheduleModes)[number]) ? (value as (typeof scheduleModes)[number]) : 'interval'; } function normalizeDailyRunTime(value: unknown) { return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : DEFAULT_DAILY_RUN_TIME; } function normalizeOptionalTimeOfDay(value: unknown) { return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null; } function toMinutesOfDay(value: string) { const [hours, minutes] = value.split(':').map((part) => Number(part)); return hours * 60 + minutes; } function buildKstDateTime(dateKey: string, timeOfDay: string) { return new Date(`${dateKey}T${timeOfDay}:00+09:00`); } function shiftKstDateKey(dateKey: string, offsetDays: number) { const baseDate = buildKstDateTime(dateKey, '00:00'); if (Number.isNaN(baseDate.getTime())) { return dateKey; } baseDate.setUTCDate(baseDate.getUTCDate() + offsetDays); return getKstDateKey(baseDate) ?? dateKey; } function normalizeScheduleExecutionMode(value: unknown): (typeof scheduleExecutionModes)[number] { return scheduleExecutionModes.includes(value as (typeof scheduleExecutionModes)[number]) ? (value as (typeof scheduleExecutionModes)[number]) : 'codex'; } function toRepeatIntervalMinutes(value: unknown, unit: unknown) { return Math.max(1, Math.ceil(toRepeatIntervalSeconds(value, unit) / 60)); } function toRepeatIntervalSeconds(value: unknown, unit: unknown) { const repeatIntervalValue = normalizeRepeatIntervalValue(value); const repeatIntervalUnit = normalizeRepeatIntervalUnit(unit); if (repeatIntervalUnit === 'second') { return repeatIntervalValue; } if (repeatIntervalUnit === 'day') { return repeatIntervalValue * 24 * 60 * 60; } if (repeatIntervalUnit === 'week') { return repeatIntervalValue * 7 * 24 * 60 * 60; } if (repeatIntervalUnit === 'month') { return repeatIntervalValue * 30 * 24 * 60 * 60; } if (repeatIntervalUnit === 'hour') { return repeatIntervalValue * 60 * 60; } return repeatIntervalValue * 60; } function resolveStoredRepeatIntervalValue(row: Record) { return normalizeRepeatIntervalValue(row.repeat_interval_value ?? row.repeat_interval_minutes ?? 60); } function resolveStoredRepeatIntervalUnit(row: Record) { return normalizeRepeatIntervalUnit(row.repeat_interval_unit); } function resolveStoredRepeatIntervalSeconds(row: Record) { return toRepeatIntervalSeconds(resolveStoredRepeatIntervalValue(row), resolveStoredRepeatIntervalUnit(row)); } function resolveStoredRepeatIntervalMinutes(row: Record) { return toRepeatIntervalMinutes(resolveStoredRepeatIntervalValue(row), resolveStoredRepeatIntervalUnit(row)); } function normalizeBoolean(value: unknown, fallback: boolean) { if (typeof value === 'boolean') { return value; } if (value === 0 || value === '0' || value === 'false') { return false; } if (value === 1 || value === '1' || value === 'true') { return true; } return fallback; } function buildManagedServiceFailureSummary(result: { title?: string; skipped?: boolean; itemCount?: number; ios?: { ok?: boolean; skipped?: boolean; reason?: string; sentCount?: number; failedCount?: number }; web?: { ok?: boolean; skipped?: boolean; reason?: string; sentCount?: number; failedCount?: number }; }) { const summaryParts = [ result.title ? `title=${result.title}` : null, `itemCount=${Number(result.itemCount ?? 0)}`, `skipped=${result.skipped ? 'true' : 'false'}`, `webOk=${result.web?.ok ? 'true' : 'false'}`, `webSkipped=${result.web?.skipped ? 'true' : 'false'}`, `webSent=${Number(result.web?.sentCount ?? 0)}`, `webFailed=${Number(result.web?.failedCount ?? 0)}`, result.web?.reason ? `webReason=${result.web.reason}` : null, `iosOk=${result.ios?.ok ? 'true' : 'false'}`, `iosSkipped=${result.ios?.skipped ? 'true' : 'false'}`, result.ios?.reason ? `iosReason=${result.ios.reason}` : null, ].filter((value): value is string => Boolean(value)); return summaryParts.join(', '); } export function buildScheduledBoardPostTitle(row: Record) { const workId = normalizeScheduledWorkId(String(row.work_id ?? '반복작업')); const note = String(row.note ?? '') .split('\n') .map((line) => line.trim()) .find(Boolean); const title = note || workId; return title.length > 200 ? `${title.slice(0, 197).trimEnd()}...` : title; } export function buildScheduledPlanWorkIdBase(row: Record) { const workId = normalizeScheduledWorkId(String(row.work_id ?? '반복작업')); if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { return workId; } const scheduleId = Number(row.id); if (!Number.isInteger(scheduleId) || scheduleId <= 0) { return workId; } return normalizeScheduledWorkId(`schedule-${scheduleId}-${workId}`); } export function shouldCreatePlanForScheduleExecution(row: Record) { return normalizeScheduleExecutionMode(row.execution_mode) === 'managed-service'; } function getKstDateKey(value: unknown) { if (!value) { return null; } const date = value instanceof Date ? value : new Date(String(value)); if (Number.isNaN(date.getTime())) { return null; } return getKstNowParts(date).dateKey; } function isDailyScheduleDue(row: Record, now: Date) { const nowParts = getKstNowParts(now); const [hours, minutes] = normalizeDailyRunTime(row.daily_run_time).split(':').map((value) => Number(value)); const scheduledMinutesOfDay = hours * 60 + minutes; return nowParts.minutesOfDay >= scheduledMinutesOfDay && getKstDateKey(row.last_registered_at) !== nowParts.dateKey; } function isIntervalScheduleDue(row: Record, now: Date) { const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null; const repeatIntervalSeconds = resolveStoredRepeatIntervalSeconds(row); if (!lastRegisteredAt || Number.isNaN(lastRegisteredAt.getTime())) { const startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); if (startTime) { const nowParts = getKstNowParts(now); const endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); const startMinutesOfDay = toMinutesOfDay(startTime); const endMinutesOfDay = endTime ? toMinutesOfDay(endTime) : null; const anchorDateKey = endMinutesOfDay !== null && startMinutesOfDay > endMinutesOfDay && nowParts.minutesOfDay <= endMinutesOfDay ? shiftKstDateKey(nowParts.dateKey, -1) : nowParts.dateKey; const startAt = buildKstDateTime(anchorDateKey, startTime); if (!Number.isNaN(startAt.getTime())) { if (normalizeBoolean(row.immediate_run_enabled, true)) { return now.getTime() >= startAt.getTime(); } return now.getTime() >= startAt.getTime() + repeatIntervalSeconds * 1000; } } } const intervalBaseAt = lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime()) ? lastRegisteredAt : new Date(String(row.created_at ?? now.toISOString())); if (Number.isNaN(intervalBaseAt.getTime())) { return true; } return intervalBaseAt.getTime() <= now.getTime() - repeatIntervalSeconds * 1000; } function isWithinRepeatWindow(row: Record, now: Date) { const startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); const endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); if (!startTime && !endTime) { return true; } const nowMinutesOfDay = getKstNowParts(now).minutesOfDay; const startMinutesOfDay = startTime ? toMinutesOfDay(startTime) : null; const endMinutesOfDay = endTime ? toMinutesOfDay(endTime) : null; if (startMinutesOfDay !== null && endMinutesOfDay !== null) { if (startMinutesOfDay <= endMinutesOfDay) { return nowMinutesOfDay >= startMinutesOfDay && nowMinutesOfDay <= endMinutesOfDay; } return nowMinutesOfDay >= startMinutesOfDay || nowMinutesOfDay <= endMinutesOfDay; } if (startMinutesOfDay !== null) { return nowMinutesOfDay >= startMinutesOfDay; } return nowMinutesOfDay <= (endMinutesOfDay ?? nowMinutesOfDay); } function isScheduleDue(row: Record, now: Date) { const scheduleMode = normalizeScheduleMode(row.schedule_mode); if (scheduleMode === 'interval' && !isWithinRepeatWindow(row, now)) { return false; } if (!row.last_registered_at && normalizeBoolean(row.immediate_run_enabled, true)) { return true; } return scheduleMode === 'daily' ? isDailyScheduleDue(row, now) : isIntervalScheduleDue(row, now); } export function isPlanScheduledTaskDue(row: Record, now = new Date()) { return isScheduleDue(row, now); } export function mapPlanScheduledTaskRow(row: Record) { const repeatIntervalValue = resolveStoredRepeatIntervalValue(row); const repeatIntervalUnit = resolveStoredRepeatIntervalUnit(row); return { id: row.id, workId: row.work_id, note: row.note, automationType: resolveStoredAutomationTypeId(row), automationContextIds: parseAutomationContextIds(row.automation_context_ids_json), releaseTarget: row.release_target, jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), suppressWebPush: Boolean(row.suppress_web_push ?? false), enabled: Boolean(row.enabled ?? true), immediateRunEnabled: normalizeBoolean(row.immediate_run_enabled, true), refreshContextSnapshotOnNextRun: normalizeBoolean(row.context_snapshot_refresh_requested, false), executionMode: normalizeScheduleExecutionMode(row.execution_mode), managedServiceKey: typeof row.managed_service_key === 'string' ? row.managed_service_key : null, managedServicePackageName: typeof row.managed_service_package_name === 'string' ? row.managed_service_package_name : null, managedServiceDirectory: typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null, managedServiceManifestPath: typeof row.managed_service_manifest_path === 'string' ? row.managed_service_manifest_path : null, managedServiceGeneratedAt: row.managed_service_generated_at ?? null, managedServiceGenerationPlanItemId: row.managed_service_generation_plan_item_id === null || row.managed_service_generation_plan_item_id === undefined ? null : Number(row.managed_service_generation_plan_item_id), managedServiceGenerationBoardPostId: row.managed_service_generation_board_post_id === null || row.managed_service_generation_board_post_id === undefined ? null : Number(row.managed_service_generation_board_post_id), recreateManagedServiceOnNextSave: normalizeBoolean(row.managed_service_recreate_requested, false), scheduleMode: normalizeScheduleMode(row.schedule_mode), repeatIntervalValue, repeatIntervalUnit, repeatIntervalSeconds: resolveStoredRepeatIntervalSeconds(row), repeatIntervalMinutes: resolveStoredRepeatIntervalMinutes(row), dailyRunTime: normalizeDailyRunTime(row.daily_run_time), repeatWindowStartTime: normalizeOptionalTimeOfDay(row.repeat_window_start_time), repeatWindowEndTime: normalizeOptionalTimeOfDay(row.repeat_window_end_time), lastRegisteredAt: row.last_registered_at, createdAt: row.created_at, updatedAt: row.updated_at, }; } async function removeManagedServicePackage(scheduleId: number) { await removeManagedScheduleServiceArtifacts(scheduleId); } async function ensureManagedServicePackage(options: { scheduleId: number; workId: string; note: string; releaseTarget: string; automationType: string; }) { const metadata = buildManagedScheduleServiceMetadata(options.scheduleId, options.workId); await prepareManagedScheduleServiceDirectory(options.scheduleId); return metadata; } function buildScheduledManagedServicePlanWorkIdBase(row: Record) { return buildScheduledPlanWorkIdBase(row); } function isManagedServiceGenerationPlanPending(row: Record | null | undefined) { const status = String(row?.status ?? '').trim(); const workerStatus = String(row?.worker_status ?? '').trim(); if (!status) { return false; } if (['완료', '릴리즈완료', '작업완료'].includes(status)) { return false; } if (['작업취소', '브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패'].includes(workerStatus)) { return false; } return true; } function buildManagedServiceGenerationPlanNote(options: { row: Record; scheduleSnapshot: { requestPath: string; contextPath: string; manifestPath: string; }; reason: 'requested' | 'missing'; }) { const scheduleId = Number(options.row.id); const metadata = buildManagedScheduleServiceMetadata(scheduleId, String(options.row.work_id ?? '반복작업')); const requestedReason = options.reason === 'requested' ? '사용자가 패키지 재처리를 요청함' : '실행 대상 서비스 패키지가 누락됨'; return [ '## 스케줄 서비스 패키지 생성 지시', `- 대상 스케줄 PK: ${scheduleId}`, `- 작업 ID base: ${buildScheduledManagedServicePlanWorkIdBase(options.row)}`, `- 생성 사유: ${requestedReason}`, `- 패키지 루트: ${metadata.relativeDirectory}`, `- 서비스 키: ${metadata.serviceKey}`, `- 패키지명: ${metadata.packageName}`, '', '반드시 아래 파일을 위 패키지 루트에 직접 생성하거나 갱신하세요.', `- ${metadata.readmePath}`, `- ${metadata.sourcePath}`, `- ${metadata.runtimePath}`, `- ${metadata.manifestPath}`, '', '생성 규칙:', '- service.ts 와 service.mjs 는 둘 다 유지합니다.', '- service.mjs 는 스케줄 실행 시 직접 import 되어 `run(runtime)` 를 호출합니다.', '- README.md 에 서비스 목적, 실행 방식, 의존 경로, 검증 방법을 적습니다.', '- service-manifest.json 에 schedulePk, workId, serviceKey, packageName, relativeDirectory, sourcePath, runtimePath, readmePath, createdAt 를 기록합니다.', '- managed-service 같은 하위 임시 디렉터리를 다시 만들지 말고, 위 루트 경로만 사용합니다.', '- 실제 서비스 로직 요구사항은 아래 request/context 문서를 읽고 구현하세요. 본 자동화 메모 안에 원문 요구사항을 다시 복사하지 마세요.', '- generic placeholder 같은 빈 구현으로 남기지 마세요.', '', '참고 문서:', `- 요청 정리: ${options.scheduleSnapshot.requestPath}`, `- 컨텍스트 정리: ${options.scheduleSnapshot.contextPath}`, `- 스냅샷 manifest: ${options.scheduleSnapshot.manifestPath}`, '', '검증 기준:', '- 생성 직후 위 네 파일이 모두 존재해야 합니다.', '- service.mjs 가 현재 저장소 코드 기준으로 바로 실행 가능한 상태여야 합니다.', ] .filter(Boolean) .join('\n') .trim(); } async function queueManagedServiceGenerationPlan(options: { row: Record; scheduleSnapshot: { requestPath: string; contextPath: string; manifestPath: string; }; automationContextIds: string[]; reason: 'requested' | 'missing'; }) { const boardPost = await createBoardPost({ title: `[스케줄 서비스 생성] ${buildScheduledBoardPostTitle(options.row)}`, content: buildManagedServiceGenerationPlanNote({ row: options.row, scheduleSnapshot: options.scheduleSnapshot, reason: options.reason, }), attachments: [], automationType: String(options.row.automation_type_id ?? options.row.automation_type ?? 'none'), automationContextIds: options.automationContextIds, }); const automationReceipt = await receiveBoardPostAutomation(Number(boardPost.id), { planWorkIdBase: buildScheduledManagedServicePlanWorkIdBase(options.row), planWorkIdSuffixLabel: 'service', suppressWebPush: Boolean(options.row.suppress_web_push ?? false), }); if (!automationReceipt?.planItemId) { throw new Error(`Plan 스케줄 #${options.row.id} 서비스 패키지 자동 접수에 실패했습니다.`); } const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: options.row.id }) .update({ managed_service_generation_board_post_id: Number(boardPost.id), managed_service_generation_plan_item_id: Number(automationReceipt.planItemId), managed_service_recreate_requested: false, updated_at: db.fn.now(), }) .returning('*'); const createdPlan = await db(PLAN_TABLE).where({ id: automationReceipt.planItemId }).first(); return { row: updatedRows[0] ?? options.row, createdPlan: createdPlan ?? null, createdBoardPost: boardPost, }; } async function ensureManagedServiceExecutionReady(options: { row: Record; scheduleSnapshot: { requestPath: string; contextPath: string; manifestPath: string; }; automationContextIds: string[]; }) { const { row, scheduleSnapshot, automationContextIds } = options; if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { return { row, ready: true, generationTriggered: false, reason: null as 'requested' | 'missing' | null, createdPlan: null as Record | null, createdBoardPosts: [] as Array>, }; } const recreateRequested = normalizeBoolean(row.managed_service_recreate_requested, false); const packageExists = await hasManagedScheduleServicePackage( typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null, ); if (packageExists && !recreateRequested) { return { row, ready: true, generationTriggered: false, reason: null as 'requested' | 'missing' | null, createdPlan: null as Record | null, createdBoardPosts: [] as Array>, }; } const existingPlanId = Number(row.managed_service_generation_plan_item_id ?? 0); const existingPlan = existingPlanId > 0 ? await db(PLAN_TABLE).where({ id: existingPlanId }).first() : null; if (packageExists && existingPlan && !recreateRequested) { const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: row.id }) .update({ managed_service_recreate_requested: false, managed_service_generated_at: db.fn.now(), updated_at: db.fn.now(), }) .returning('*'); return { row: updatedRows[0] ?? row, ready: true, generationTriggered: false, reason: null as 'requested' | 'missing' | null, createdPlan: null as Record | null, createdBoardPosts: [] as Array>, }; } if (existingPlan && isManagedServiceGenerationPlanPending(existingPlan)) { return { row, ready: false, generationTriggered: false, reason: recreateRequested ? ('requested' as const) : ('missing' as const), createdPlan: existingPlan, createdBoardPosts: [], }; } await removeManagedServicePackage(Number(row.id)); await ensureManagedServicePackage({ scheduleId: Number(row.id), workId: String(row.work_id ?? '반복작업'), note: String(row.note ?? ''), releaseTarget: String(row.release_target ?? 'release'), automationType: String(row.automation_type_id ?? row.automation_type ?? 'none'), }); const queued = await queueManagedServiceGenerationPlan({ row, scheduleSnapshot, automationContextIds, reason: recreateRequested ? 'requested' : 'missing', }); return { row: queued.row, ready: false, generationTriggered: true, reason: recreateRequested ? ('requested' as const) : ('missing' as const), createdPlan: queued.createdPlan, createdBoardPosts: [queued.createdBoardPost], }; } async function ensurePlanScheduledTaskColumn( columnName: string, addColumn: (table: any) => void, ) { const hasColumn = await db.schema.hasColumn(PLAN_SCHEDULED_TASK_TABLE, columnName); if (hasColumn) { return; } await db.schema.alterTable(PLAN_SCHEDULED_TASK_TABLE, (table) => { addColumn(table); }); } export async function ensurePlanScheduledTaskTable() { const exists = await db.schema.hasTable(PLAN_SCHEDULED_TASK_TABLE); if (!exists) { await db.schema.createTable(PLAN_SCHEDULED_TASK_TABLE, (table) => { table.increments('id').primary(); table.string('work_id', 120).notNullable().defaultTo('반복작업'); table.text('note').notNullable().defaultTo(''); table.string('automation_type', 40).notNullable().defaultTo('none'); table.string('automation_type_id', 120).nullable(); table.text('automation_context_ids_json').notNullable().defaultTo('[]'); table.string('release_target', 120).notNullable().defaultTo('release'); table.boolean('jangsing_processing_required').notNullable().defaultTo(true); table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); table.boolean('suppress_web_push').notNullable().defaultTo(false); table.boolean('enabled').notNullable().defaultTo(true); table.boolean('immediate_run_enabled').notNullable().defaultTo(true); table.boolean('context_snapshot_refresh_requested').notNullable().defaultTo(false); table.string('execution_mode', 40).notNullable().defaultTo('codex'); table.string('managed_service_key', 160).nullable(); table.string('managed_service_package_name', 200).nullable(); table.text('managed_service_directory').nullable(); table.text('managed_service_manifest_path').nullable(); table.timestamp('managed_service_generated_at', { useTz: true }).nullable(); table.integer('managed_service_generation_plan_item_id').nullable(); table.integer('managed_service_generation_board_post_id').nullable(); table.boolean('managed_service_recreate_requested').notNullable().defaultTo(false); table.string('schedule_mode', 20).notNullable().defaultTo('interval'); table.integer('repeat_interval_value').notNullable().defaultTo(60); table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute'); table.integer('repeat_interval_minutes').notNullable().defaultTo(60); table.integer('repeat_interval_seconds').notNullable().defaultTo(DEFAULT_REPEAT_INTERVAL_SECONDS); table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); table.string('repeat_window_start_time', 5).nullable(); table.string('repeat_window_end_time', 5).nullable(); table.timestamp('last_registered_at', { useTz: true }).nullable(); table.timestamp('context_snapshot_generated_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); return; } await ensurePlanScheduledTaskColumn('release_target', (table) => { table.string('release_target', 120).notNullable().defaultTo('release'); }); await ensurePlanScheduledTaskColumn('automation_type', (table) => { table.string('automation_type', 40).notNullable().defaultTo('none'); }); await ensurePlanScheduledTaskColumn('automation_type_id', (table) => { table.string('automation_type_id', 120).nullable(); }); await ensurePlanScheduledTaskColumn('automation_context_ids_json', (table) => { table.text('automation_context_ids_json').notNullable().defaultTo('[]'); }); await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => { table.boolean('jangsing_processing_required').notNullable().defaultTo(true); }); await ensurePlanScheduledTaskColumn('auto_deploy_to_main', (table) => { table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); }); await ensurePlanScheduledTaskColumn('suppress_web_push', (table) => { table.boolean('suppress_web_push').notNullable().defaultTo(false); }); await ensurePlanScheduledTaskColumn('enabled', (table) => { table.boolean('enabled').notNullable().defaultTo(true); }); await ensurePlanScheduledTaskColumn('immediate_run_enabled', (table) => { table.boolean('immediate_run_enabled').notNullable().defaultTo(true); }); await ensurePlanScheduledTaskColumn('context_snapshot_refresh_requested', (table) => { table.boolean('context_snapshot_refresh_requested').notNullable().defaultTo(false); }); await ensurePlanScheduledTaskColumn('execution_mode', (table) => { table.string('execution_mode', 40).notNullable().defaultTo('codex'); }); await ensurePlanScheduledTaskColumn('managed_service_key', (table) => { table.string('managed_service_key', 160).nullable(); }); await ensurePlanScheduledTaskColumn('managed_service_package_name', (table) => { table.string('managed_service_package_name', 200).nullable(); }); await ensurePlanScheduledTaskColumn('managed_service_directory', (table) => { table.text('managed_service_directory').nullable(); }); await ensurePlanScheduledTaskColumn('managed_service_manifest_path', (table) => { table.text('managed_service_manifest_path').nullable(); }); await ensurePlanScheduledTaskColumn('managed_service_generated_at', (table) => { table.timestamp('managed_service_generated_at', { useTz: true }).nullable(); }); await ensurePlanScheduledTaskColumn('managed_service_generation_plan_item_id', (table) => { table.integer('managed_service_generation_plan_item_id').nullable(); }); await ensurePlanScheduledTaskColumn('managed_service_generation_board_post_id', (table) => { table.integer('managed_service_generation_board_post_id').nullable(); }); await ensurePlanScheduledTaskColumn('managed_service_recreate_requested', (table) => { table.boolean('managed_service_recreate_requested').notNullable().defaultTo(false); }); await ensurePlanScheduledTaskColumn('schedule_mode', (table) => { table.string('schedule_mode', 20).notNullable().defaultTo('interval'); }); await ensurePlanScheduledTaskColumn('repeat_interval_value', (table) => { table.integer('repeat_interval_value').notNullable().defaultTo(60); }); await ensurePlanScheduledTaskColumn('repeat_interval_unit', (table) => { table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute'); }); await ensurePlanScheduledTaskColumn('repeat_interval_minutes', (table) => { table.integer('repeat_interval_minutes').notNullable().defaultTo(60); }); await ensurePlanScheduledTaskColumn('repeat_interval_seconds', (table) => { table.integer('repeat_interval_seconds').notNullable().defaultTo(DEFAULT_REPEAT_INTERVAL_SECONDS); }); await ensurePlanScheduledTaskColumn('daily_run_time', (table) => { table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); }); await ensurePlanScheduledTaskColumn('repeat_window_start_time', (table) => { table.string('repeat_window_start_time', 5).nullable(); }); await ensurePlanScheduledTaskColumn('repeat_window_end_time', (table) => { table.string('repeat_window_end_time', 5).nullable(); }); await ensurePlanScheduledTaskColumn('last_registered_at', (table) => { table.timestamp('last_registered_at', { useTz: true }).nullable(); }); await ensurePlanScheduledTaskColumn('context_snapshot_generated_at', (table) => { table.timestamp('context_snapshot_generated_at', { useTz: true }).nullable(); }); await ensurePlanScheduledTaskColumn('created_at', (table) => { table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); await ensurePlanScheduledTaskColumn('updated_at', (table) => { table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); await db(PLAN_SCHEDULED_TASK_TABLE) .where({ automation_type: 'plan_registration' }) .update({ automation_type: 'plan' }); await db(PLAN_SCHEDULED_TASK_TABLE) .where({ automation_type: 'general_development' }) .update({ automation_type: 'auto_worker' }); await db(PLAN_SCHEDULED_TASK_TABLE) .whereNull('automation_type_id') .update({ automation_type_id: db.raw('automation_type'), }); await db(PLAN_SCHEDULED_TASK_TABLE) .where({ repeat_interval_unit: 'minute' }) .update({ repeat_interval_value: db.raw('repeat_interval_minutes'), }); await db(PLAN_SCHEDULED_TASK_TABLE) .whereNull('repeat_interval_seconds') .update({ repeat_interval_seconds: db.raw('repeat_interval_minutes * 60'), }); await db(PLAN_SCHEDULED_TASK_TABLE) .whereNull('suppress_web_push') .update({ suppress_web_push: false, }); const existingRows = await db(PLAN_SCHEDULED_TASK_TABLE).select( 'id', 'repeat_interval_value', 'repeat_interval_unit', 'repeat_interval_minutes', 'repeat_interval_seconds', ); for (const row of existingRows) { const repeatIntervalSeconds = resolveStoredRepeatIntervalSeconds(row); const repeatIntervalMinutes = resolveStoredRepeatIntervalMinutes(row); const currentSeconds = normalizeRepeatIntervalSeconds(row.repeat_interval_seconds ?? repeatIntervalSeconds); const currentMinutes = normalizeRepeatIntervalMinutes(row.repeat_interval_minutes ?? repeatIntervalMinutes); if (currentSeconds === repeatIntervalSeconds && currentMinutes === repeatIntervalMinutes) { continue; } await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: row.id }) .update({ repeat_interval_seconds: repeatIntervalSeconds, repeat_interval_minutes: repeatIntervalMinutes, }); } } export async function listPlanScheduledTasks() { await ensurePlanScheduledTaskTable(); return db(PLAN_SCHEDULED_TASK_TABLE).select('*').orderBy('id', 'desc'); } export async function getPlanScheduledTaskById(id: number) { await ensurePlanScheduledTaskTable(); return db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); } export async function createPlanScheduledTask(payload: z.infer) { await ensurePlanScheduledTaskTable(); const scheduleMode = normalizeScheduleMode(payload.scheduleMode); const repeatIntervalValue = normalizeRepeatIntervalValue(payload.repeatIntervalValue); const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit); const automationType = await resolveAutomationType(payload.automationType); const executionMode = normalizeScheduleExecutionMode(payload.executionMode); const shouldAcknowledgeManagedServiceRefreshOnNextRun = executionMode === 'managed-service' && Boolean(payload.recreateManagedServiceOnNextSave); const rows = await db(PLAN_SCHEDULED_TASK_TABLE) .insert({ work_id: normalizeScheduledWorkId(payload.workId), note: payload.note, automation_type: automationType.behaviorType, automation_type_id: automationType.id, automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds), release_target: payload.releaseTarget, jangsing_processing_required: payload.jangsingProcessingRequired, auto_deploy_to_main: payload.autoDeployToMain, suppress_web_push: payload.suppressWebPush, enabled: payload.enabled, immediate_run_enabled: payload.immediateRunEnabled, context_snapshot_refresh_requested: payload.refreshContextSnapshotOnNextRun, execution_mode: executionMode, managed_service_recreate_requested: shouldAcknowledgeManagedServiceRefreshOnNextRun, schedule_mode: scheduleMode, repeat_interval_value: repeatIntervalValue, repeat_interval_unit: repeatIntervalUnit, repeat_interval_seconds: toRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit), repeat_interval_minutes: toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit), daily_run_time: normalizeDailyRunTime(payload.dailyRunTime), repeat_window_start_time: normalizeOptionalTimeOfDay(payload.repeatWindowStartTime), repeat_window_end_time: normalizeOptionalTimeOfDay(payload.repeatWindowEndTime), updated_at: db.fn.now(), }) .returning('*'); let row = rows[0]; if (executionMode === 'managed-service' && row?.id) { const managedService = await ensureManagedServicePackage({ scheduleId: Number(row.id), workId: String(row.work_id ?? payload.workId ?? '반복작업'), note: String(row.note ?? payload.note ?? ''), releaseTarget: String(row.release_target ?? payload.releaseTarget ?? 'release'), automationType: String(row.automation_type_id ?? row.automation_type ?? automationType.id), }); const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: row.id }) .update({ managed_service_key: managedService.serviceKey, managed_service_package_name: managedService.packageName, managed_service_directory: managedService.relativeDirectory, managed_service_manifest_path: managedService.manifestPath, managed_service_generated_at: null, managed_service_generation_plan_item_id: null, managed_service_generation_board_post_id: null, managed_service_recreate_requested: shouldAcknowledgeManagedServiceRefreshOnNextRun, updated_at: db.fn.now(), }) .returning('*'); row = updatedRows[0] ?? row; } return row; } export async function updatePlanScheduledTask(id: number, payload: z.infer) { await ensurePlanScheduledTaskTable(); const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); if (!currentRow) { return null; } const scheduleMode = normalizeScheduleMode(payload.scheduleMode ?? currentRow.schedule_mode); const repeatIntervalValue = normalizeRepeatIntervalValue( payload.repeatIntervalValue ?? currentRow.repeat_interval_value ?? currentRow.repeat_interval_minutes ?? 60, ); const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit ?? currentRow.repeat_interval_unit); const executionMode = normalizeScheduleExecutionMode(payload.executionMode ?? currentRow.execution_mode); const automationType = await resolveAutomationType( payload.automationType ?? currentRow.automation_type_id ?? currentRow.automation_type, ); const shouldRecreateManagedService = executionMode === 'managed-service' && (normalizeBoolean(payload.recreateManagedServiceOnNextSave, false) || executionMode !== normalizeScheduleExecutionMode(currentRow.execution_mode) || (payload.workId !== undefined && normalizeScheduledWorkId(payload.workId) !== String(currentRow.work_id ?? ''))); const rows = await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id }) .update({ work_id: payload.workId === undefined ? currentRow.work_id : normalizeScheduledWorkId(payload.workId), note: payload.note ?? currentRow.note, automation_type: automationType.behaviorType, automation_type_id: automationType.id, automation_context_ids_json: stringifyAutomationContextIds( payload.automationContextIds ?? parseAutomationContextIds(currentRow.automation_context_ids_json), ), release_target: payload.releaseTarget ?? currentRow.release_target ?? 'release', jangsing_processing_required: payload.jangsingProcessingRequired ?? currentRow.jangsing_processing_required ?? true, auto_deploy_to_main: payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true, suppress_web_push: payload.suppressWebPush ?? currentRow.suppress_web_push ?? false, enabled: payload.enabled ?? currentRow.enabled ?? true, immediate_run_enabled: payload.immediateRunEnabled ?? currentRow.immediate_run_enabled ?? true, context_snapshot_refresh_requested: payload.refreshContextSnapshotOnNextRun ?? currentRow.context_snapshot_refresh_requested ?? false, execution_mode: executionMode, managed_service_recreate_requested: shouldRecreateManagedService, managed_service_generation_plan_item_id: shouldRecreateManagedService || executionMode !== normalizeScheduleExecutionMode(currentRow.execution_mode) ? null : currentRow.managed_service_generation_plan_item_id ?? null, managed_service_generation_board_post_id: shouldRecreateManagedService || executionMode !== normalizeScheduleExecutionMode(currentRow.execution_mode) ? null : currentRow.managed_service_generation_board_post_id ?? null, schedule_mode: scheduleMode, repeat_interval_value: repeatIntervalValue, repeat_interval_unit: repeatIntervalUnit, repeat_interval_seconds: toRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit), repeat_interval_minutes: toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit), daily_run_time: normalizeDailyRunTime(payload.dailyRunTime ?? currentRow.daily_run_time), repeat_window_start_time: normalizeOptionalTimeOfDay( payload.repeatWindowStartTime ?? currentRow.repeat_window_start_time, ), repeat_window_end_time: normalizeOptionalTimeOfDay( payload.repeatWindowEndTime ?? currentRow.repeat_window_end_time, ), context_snapshot_generated_at: payload.enabled !== undefined && Boolean(payload.enabled) !== Boolean(currentRow.enabled ?? true) ? null : currentRow.context_snapshot_generated_at ?? null, updated_at: db.fn.now(), }) .returning('*'); let row = rows[0] ?? null; if (!row) { return null; } if (executionMode !== 'managed-service') { await removeManagedServicePackage(Number(row.id)); const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: row.id }) .update({ managed_service_key: null, managed_service_package_name: null, managed_service_directory: null, managed_service_manifest_path: null, managed_service_generated_at: null, managed_service_generation_plan_item_id: null, managed_service_generation_board_post_id: null, managed_service_recreate_requested: false, updated_at: db.fn.now(), }) .returning('*'); return updatedRows[0] ?? row; } if ( shouldRecreateManagedService || !String(row.managed_service_directory ?? '').trim() || !String(row.managed_service_key ?? '').trim() ) { await removeManagedServicePackage(Number(row.id)); const managedService = await ensureManagedServicePackage({ scheduleId: Number(row.id), workId: String(row.work_id ?? currentRow.work_id ?? '반복작업'), note: String(row.note ?? currentRow.note ?? ''), releaseTarget: String(row.release_target ?? currentRow.release_target ?? 'release'), automationType: String(row.automation_type_id ?? row.automation_type ?? automationType.id), }); const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: row.id }) .update({ managed_service_key: managedService.serviceKey, managed_service_package_name: managedService.packageName, managed_service_directory: managedService.relativeDirectory, managed_service_manifest_path: managedService.manifestPath, managed_service_generated_at: null, managed_service_generation_plan_item_id: null, managed_service_generation_board_post_id: null, managed_service_recreate_requested: shouldRecreateManagedService, updated_at: db.fn.now(), }) .returning('*'); row = updatedRows[0] ?? row; } return row; } export async function deletePlanScheduledTask(id: number) { await ensurePlanScheduledTaskTable(); const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); if (!currentRow) { return null; } await removeManagedServicePackage(id); await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).delete(); return currentRow; } export async function registerDuePlanScheduledTasks(now = new Date()) { await ensurePlanTable(); await ensurePlanScheduledTaskTable(); const rows = await db(PLAN_SCHEDULED_TASK_TABLE).where({ enabled: true }).orderBy('id', 'asc'); const registered = []; for (const row of rows.filter((item) => isScheduleDue(item, now))) { const registration = await registerPlanScheduledTaskRow(row, now); if (registration.createdPlan || registration.createdBoardPosts.length > 0) { registered.push(registration); } } return registered; } async function registerPlanScheduledTaskRow(row: Record, now: Date) { const executionMode = normalizeScheduleExecutionMode(row.execution_mode); const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json); const shouldRefreshSnapshot = !row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false); const scheduleSnapshot = shouldRefreshSnapshot ? await ensureSchedulePromptSnapshot({ scheduleId: Number(row.id), workId: buildScheduledPlanWorkIdBase(row), note: String(row.note ?? ''), forceRefresh: true, }) : { directory: `.auto_codex/schedule/${row.id}`, requestPath: `.auto_codex/schedule/${row.id}/request.md`, contextPath: `.auto_codex/schedule/${row.id}/context.md`, manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`, }; const managedServiceReady = await ensureManagedServiceExecutionReady({ row, scheduleSnapshot, automationContextIds, }); const effectiveRow = managedServiceReady.row; const scheduleNote = [ String(effectiveRow.note ?? '').trim(), '', '## 스케줄 전용 참조 문서', `- ${scheduleSnapshot.requestPath}`, `- ${scheduleSnapshot.contextPath}`, '', '위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.', executionMode === 'managed-service' ? [ '', '## 스케줄 관리 서비스', `- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`, `- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`)}`, managedServiceReady.ready ? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.' : `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`, managedServiceReady.reason ? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}` : null, '- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.', ].join('\n') : null, ] .filter((value): value is string => Boolean(value)) .join('\n') .trim(); if (executionMode === 'managed-service') { if (!managedServiceReady.ready) { return { createdPlan: managedServiceReady.createdPlan, createdBoardPosts: managedServiceReady.createdBoardPosts, }; } const managedServiceDirectory = String( effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`, ); const managedServiceResult = await runManagedScheduleService(managedServiceDirectory); if (!managedServiceResult.ok) { throw new Error( `스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`, ); } const createdPlan = await createCompletedPlanExecutionLogItem({ workId: buildScheduledPlanWorkIdBase(effectiveRow), note: scheduleNote, automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), automationContextIds, releaseTarget: String(effectiveRow.release_target ?? 'release'), jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true), autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true), suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false), repeatRequestEnabled: false, repeatIntervalMinutes: 60, }); const managedServiceChangedFiles = [ `${managedServiceDirectory}/README.md`, `${managedServiceDirectory}/service.ts`, `${managedServiceDirectory}/service.mjs`, `${managedServiceDirectory}/service-manifest.json`, ]; await createPlanSourceWorkHistory(Number(createdPlan.id), { summary: [ `스케줄 서비스 실행: schedule #${effectiveRow.id}`, `서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`, `결과: ${ managedServiceResult.skipped ? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})` : `${managedServiceResult.itemCount}건 전송 시도` }`, ].join('\n'), branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'), commitHash: null, changedFiles: managedServiceChangedFiles, commandLog: [ `schedule-managed-service run scheduleId=${String(effectiveRow.id)}`, `servicePath=${managedServiceDirectory}/service.mjs`, `itemCount=${managedServiceResult.itemCount}`, `webSent=${managedServiceResult.web.sentCount}`, `webFailed=${managedServiceResult.web.failedCount}`, `skipped=${managedServiceResult.skipped ? 'true' : 'false'}`, `reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`, ].join('\n'), diffText: null, sourceFiles: [], }); await createPlanActionHistory( Number(createdPlan.id), '스케줄서비스실행', `Plan 스케줄 #${effectiveRow.id} 전용 서비스 파일을 직접 실행했습니다.`, ); await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: effectiveRow.id }) .update({ last_registered_at: now, context_snapshot_generated_at: now, context_snapshot_refresh_requested: false, managed_service_generated_at: db.fn.now(), updated_at: db.fn.now(), }); return { createdPlan, createdBoardPosts: [], }; } const boardPost = await createBoardPost({ title: buildScheduledBoardPostTitle(effectiveRow), content: scheduleNote, attachments: [], automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), automationContextIds, }); await db(PLAN_SCHEDULED_TASK_TABLE) .where({ id: effectiveRow.id }) .update({ last_registered_at: now, context_snapshot_generated_at: now, context_snapshot_refresh_requested: false, updated_at: db.fn.now(), }); return { createdPlan: null, createdBoardPosts: [boardPost], }; } export async function registerPlanScheduledTaskNow( id: number, now = new Date(), options?: { ignoreScheduleDue?: boolean; forceManagedServiceGeneration?: boolean; }, ) { await ensurePlanTable(); await ensurePlanScheduledTaskTable(); const row = options?.forceManagedServiceGeneration ? await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first() : await db(PLAN_SCHEDULED_TASK_TABLE).where({ id, enabled: true }).first(); if ( !row || (!options?.forceManagedServiceGeneration && !options?.ignoreScheduleDue && !isScheduleDue(row, now)) ) { return null; } return registerPlanScheduledTaskRow(row, now); }