Files
ai-code-app/etc/servers/work-server/src/services/plan-schedule-service.ts

1308 lines
53 KiB
TypeScript
Executable File

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<string, unknown>) {
return normalizeRepeatIntervalValue(row.repeat_interval_value ?? row.repeat_interval_minutes ?? 60);
}
function resolveStoredRepeatIntervalUnit(row: Record<string, unknown>) {
return normalizeRepeatIntervalUnit(row.repeat_interval_unit);
}
function resolveStoredRepeatIntervalSeconds(row: Record<string, unknown>) {
return toRepeatIntervalSeconds(resolveStoredRepeatIntervalValue(row), resolveStoredRepeatIntervalUnit(row));
}
function resolveStoredRepeatIntervalMinutes(row: Record<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, now = new Date()) {
return isScheduleDue(row, now);
}
export function mapPlanScheduledTaskRow(row: Record<string, unknown>) {
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<string, unknown>) {
return buildScheduledPlanWorkIdBase(row);
}
function isManagedServiceGenerationPlanPending(row: Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown> | null,
createdBoardPosts: [] as Array<Record<string, unknown>>,
};
}
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<string, unknown> | null,
createdBoardPosts: [] as Array<Record<string, unknown>>,
};
}
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<string, unknown> | null,
createdBoardPosts: [] as Array<Record<string, unknown>>,
};
}
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<typeof createPlanScheduledTaskSchema>) {
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<typeof updatePlanScheduledTaskSchema>) {
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<string, unknown>, 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);
}