1308 lines
53 KiB
TypeScript
Executable File
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);
|
|
}
|