hanoman 0.1.25 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli.js +122 -1
- package/dist/server.js +490 -4
- package/package.json +1 -1
- package/prisma/migrations/20260811000000_scheduler_cron/migration.sql +34 -0
- package/prisma/schema.prisma +45 -0
- package/web/assets/{index-CnAQHtuX.css → index-B0GzvSQL.css} +1 -1
- package/web/assets/{index-D68Z05gR.js → index-BfOACraN.js} +1544 -1544
- package/web/index.html +2 -2
package/dist/server.js
CHANGED
|
@@ -5233,11 +5233,93 @@ var init_prd_status = __esm({
|
|
|
5233
5233
|
}
|
|
5234
5234
|
});
|
|
5235
5235
|
|
|
5236
|
+
// ../shared/src/cron-expr.ts
|
|
5237
|
+
function parseField(raw, lo, hi) {
|
|
5238
|
+
const out3 = /* @__PURE__ */ new Set();
|
|
5239
|
+
for (const part of raw.split(",")) {
|
|
5240
|
+
const slash = part.indexOf("/");
|
|
5241
|
+
const rangePart = slash === -1 ? part : part.slice(0, slash);
|
|
5242
|
+
const stepPart = slash === -1 ? void 0 : part.slice(slash + 1);
|
|
5243
|
+
let step = 1;
|
|
5244
|
+
if (stepPart !== void 0) {
|
|
5245
|
+
if (!/^\d{1,2}$/.test(stepPart)) return null;
|
|
5246
|
+
step = Number(stepPart);
|
|
5247
|
+
if (step < 1) return null;
|
|
5248
|
+
}
|
|
5249
|
+
let from;
|
|
5250
|
+
let to;
|
|
5251
|
+
if (rangePart === "*") {
|
|
5252
|
+
from = lo;
|
|
5253
|
+
to = hi;
|
|
5254
|
+
} else if (/^\d{1,2}$/.test(rangePart)) {
|
|
5255
|
+
from = Number(rangePart);
|
|
5256
|
+
to = stepPart === void 0 ? from : hi;
|
|
5257
|
+
} else {
|
|
5258
|
+
const m = /^(\d{1,2})-(\d{1,2})$/.exec(rangePart);
|
|
5259
|
+
if (!m) return null;
|
|
5260
|
+
from = Number(m[1]);
|
|
5261
|
+
to = Number(m[2]);
|
|
5262
|
+
}
|
|
5263
|
+
if (from < lo || to > hi || from > to) return null;
|
|
5264
|
+
for (let v = from; v <= to; v += step) out3.add(v);
|
|
5265
|
+
}
|
|
5266
|
+
return out3.size ? out3 : null;
|
|
5267
|
+
}
|
|
5268
|
+
function parseCron(expr) {
|
|
5269
|
+
const parts = expr.trim().split(/\s+/);
|
|
5270
|
+
if (parts.length !== 5) return null;
|
|
5271
|
+
const sets = [];
|
|
5272
|
+
for (let i = 0; i < 5; i++) {
|
|
5273
|
+
const [lo, hi] = BOUNDS[i];
|
|
5274
|
+
const s2 = parseField(parts[i], lo, hi);
|
|
5275
|
+
if (!s2) return null;
|
|
5276
|
+
sets.push(s2);
|
|
5277
|
+
}
|
|
5278
|
+
return {
|
|
5279
|
+
minute: sets[0],
|
|
5280
|
+
hour: sets[1],
|
|
5281
|
+
dom: sets[2],
|
|
5282
|
+
month: sets[3],
|
|
5283
|
+
dow: new Set([...sets[4]].map((n2) => n2 === 7 ? 0 : n2)),
|
|
5284
|
+
domRestricted: parts[2] !== "*",
|
|
5285
|
+
dowRestricted: parts[4] !== "*"
|
|
5286
|
+
};
|
|
5287
|
+
}
|
|
5288
|
+
function dayMatches(spec, d) {
|
|
5289
|
+
if (!spec.month.has(d.getMonth() + 1)) return false;
|
|
5290
|
+
const dom = spec.dom.has(d.getDate());
|
|
5291
|
+
const dow = spec.dow.has(d.getDay());
|
|
5292
|
+
return spec.domRestricted && spec.dowRestricted ? dom || dow : dom && dow;
|
|
5293
|
+
}
|
|
5294
|
+
function nextRun(spec, after, limitDays = 400) {
|
|
5295
|
+
const hours = [...spec.hour].sort((a, b) => a - b);
|
|
5296
|
+
const minutes = [...spec.minute].sort((a, b) => a - b);
|
|
5297
|
+
const t = after.getTime();
|
|
5298
|
+
for (let d = 0; d <= limitDays; d++) {
|
|
5299
|
+
const day = new Date(after.getFullYear(), after.getMonth(), after.getDate() + d);
|
|
5300
|
+
if (!dayMatches(spec, day)) continue;
|
|
5301
|
+
for (const h of hours) {
|
|
5302
|
+
for (const mi of minutes) {
|
|
5303
|
+
const cand = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, mi, 0, 0);
|
|
5304
|
+
if (cand.getTime() > t) return cand;
|
|
5305
|
+
}
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
return null;
|
|
5309
|
+
}
|
|
5310
|
+
var BOUNDS;
|
|
5311
|
+
var init_cron_expr = __esm({
|
|
5312
|
+
"../shared/src/cron-expr.ts"() {
|
|
5313
|
+
"use strict";
|
|
5314
|
+
BOUNDS = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
5315
|
+
}
|
|
5316
|
+
});
|
|
5317
|
+
|
|
5236
5318
|
// ../shared/src/dto.ts
|
|
5237
5319
|
function flowForSource(source) {
|
|
5238
5320
|
return source === "qa" ? "qa" : source === "audit" ? "audit" : source === "goal" ? "goal" : "feature";
|
|
5239
5321
|
}
|
|
5240
|
-
var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
|
|
5322
|
+
var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus, zCronExpr, zCreateCron, zPatchCron, zSchedulerCron, zSchedulerCronRun;
|
|
5241
5323
|
var init_dto = __esm({
|
|
5242
5324
|
"../shared/src/dto.ts"() {
|
|
5243
5325
|
"use strict";
|
|
@@ -5248,6 +5330,7 @@ var init_dto = __esm({
|
|
|
5248
5330
|
init_prd_status();
|
|
5249
5331
|
init_enums();
|
|
5250
5332
|
init_spec_source();
|
|
5333
|
+
init_cron_expr();
|
|
5251
5334
|
zIssueDeviceToken = external_exports.object({ name: external_exports.string().min(1) });
|
|
5252
5335
|
zSessionResult = external_exports.object({
|
|
5253
5336
|
id: external_exports.string(),
|
|
@@ -5724,6 +5807,53 @@ var init_dto = __esm({
|
|
|
5724
5807
|
status: external_exports.string(),
|
|
5725
5808
|
createdAt: external_exports.string()
|
|
5726
5809
|
});
|
|
5810
|
+
zCronExpr = external_exports.string().trim().refine((v) => parseCron(v) !== null, "cron expression tak sah");
|
|
5811
|
+
zCreateCron = external_exports.object({
|
|
5812
|
+
project: external_exports.string().min(1),
|
|
5813
|
+
name: external_exports.string().trim().min(1).max(120),
|
|
5814
|
+
expr: zCronExpr,
|
|
5815
|
+
prompt: external_exports.string().trim().min(1).max(8e3),
|
|
5816
|
+
agent: zAgent.optional(),
|
|
5817
|
+
model: external_exports.string().optional(),
|
|
5818
|
+
effort: external_exports.string().optional(),
|
|
5819
|
+
enabled: external_exports.boolean().default(false)
|
|
5820
|
+
// default aman: cron baru lahir nonaktif
|
|
5821
|
+
});
|
|
5822
|
+
zPatchCron = external_exports.object({
|
|
5823
|
+
name: external_exports.string().trim().min(1).max(120).optional(),
|
|
5824
|
+
expr: zCronExpr.optional(),
|
|
5825
|
+
prompt: external_exports.string().trim().min(1).max(8e3).optional(),
|
|
5826
|
+
agent: zAgent.nullable().optional(),
|
|
5827
|
+
model: external_exports.string().nullable().optional(),
|
|
5828
|
+
effort: external_exports.string().nullable().optional(),
|
|
5829
|
+
enabled: external_exports.boolean().optional()
|
|
5830
|
+
});
|
|
5831
|
+
zSchedulerCron = external_exports.object({
|
|
5832
|
+
id: external_exports.string(),
|
|
5833
|
+
projectId: external_exports.string(),
|
|
5834
|
+
name: external_exports.string(),
|
|
5835
|
+
expr: external_exports.string(),
|
|
5836
|
+
prompt: external_exports.string(),
|
|
5837
|
+
agent: external_exports.string().nullable(),
|
|
5838
|
+
model: external_exports.string().nullable(),
|
|
5839
|
+
effort: external_exports.string().nullable(),
|
|
5840
|
+
enabled: external_exports.boolean(),
|
|
5841
|
+
nextRunAt: external_exports.string().nullable(),
|
|
5842
|
+
lastRunAt: external_exports.string().nullable(),
|
|
5843
|
+
createdAt: external_exports.string()
|
|
5844
|
+
});
|
|
5845
|
+
zSchedulerCronRun = external_exports.object({
|
|
5846
|
+
id: external_exports.string(),
|
|
5847
|
+
cronId: external_exports.string(),
|
|
5848
|
+
projectId: external_exports.string(),
|
|
5849
|
+
dueAt: external_exports.string(),
|
|
5850
|
+
startedAt: external_exports.string().nullable(),
|
|
5851
|
+
status: external_exports.string(),
|
|
5852
|
+
sessionId: external_exports.string().nullable(),
|
|
5853
|
+
note: external_exports.string().nullable(),
|
|
5854
|
+
manual: external_exports.boolean(),
|
|
5855
|
+
createdAt: external_exports.string()
|
|
5856
|
+
});
|
|
5727
5857
|
}
|
|
5728
5858
|
});
|
|
5729
5859
|
|
|
@@ -5870,6 +6000,13 @@ var init_api = __esm({
|
|
|
5870
6000
|
schedulerQueueRequeue: (id) => `${API}/scheduler/queue/${encodeURIComponent(id)}/requeue`,
|
|
5871
6001
|
// SPEC-523 · antrean sebagai daftar berhalaman (page/limit + status), lepas dari `state`.
|
|
5872
6002
|
schedulerQueue: `${API}/scheduler/queue`,
|
|
6003
|
+
// SPEC-646 · ADR-0112 · cronjob per project. Di bawah prefix `scheduler` seperti tetangganya,
|
|
6004
|
+
// TAPI capability-nya bukan turunan otomatis: `capabilityForRoute` memberi `crons` cabang
|
|
6005
|
+
// COOKIE_ONLY sendiri — sebuah cron adalah `POST /terminal/sessions` yang ditunda.
|
|
6006
|
+
schedulerCrons: `${API}/scheduler/crons`,
|
|
6007
|
+
schedulerCron: (id) => `${API}/scheduler/crons/${encodeURIComponent(id)}`,
|
|
6008
|
+
schedulerCronRunNow: (id) => `${API}/scheduler/crons/${encodeURIComponent(id)}/run`,
|
|
6009
|
+
schedulerCronRuns: (id) => `${API}/scheduler/crons/${encodeURIComponent(id)}/runs`,
|
|
5873
6010
|
// SPEC-409 · ADR-0091 · hanoman-lead. Semua HTTP (polling) — tak ada kanal WS baru (AC-26).
|
|
5874
6011
|
leadConfig: `${API}/lead/config`,
|
|
5875
6012
|
leadStatus: `${API}/lead/status`,
|
|
@@ -7442,6 +7579,7 @@ var init_src = __esm({
|
|
|
7442
7579
|
init_mcp();
|
|
7443
7580
|
init_auto_merge();
|
|
7444
7581
|
init_changelog();
|
|
7582
|
+
init_cron_expr();
|
|
7445
7583
|
}
|
|
7446
7584
|
});
|
|
7447
7585
|
|
|
@@ -7815,6 +7953,20 @@ Stack: ${project.stack || "\u2014"}`,
|
|
|
7815
7953
|
${REVERSE_STANDARD}`
|
|
7816
7954
|
].filter(Boolean).join("\n\n");
|
|
7817
7955
|
}
|
|
7956
|
+
function cronPrompt(project, cron) {
|
|
7957
|
+
return [
|
|
7958
|
+
`hanoman cron "${cron.name}". Pemeriksaan rutin terjadwal di project ini \u2014 bukan sesi backlog: tak ada fase, tak ada plan, tak ada stage yang harus digerakkan.`,
|
|
7959
|
+
`Bila kamu menemukan masalah yang layak dikerjakan, FILEKAN sebagai backlog item lewat \`POST /api/specs\` (lihat docs/agent-integration.md untuk bentuk payload & auth), jangan hanya melaporkannya ke terminal \u2014 temuan yang cuma tertulis di log sesi akan hilang. Satu masalah = satu backlog item, judul spesifik.`,
|
|
7960
|
+
`Jangan mengerjakan sendiri perbaikan besar dalam sesi ini kecuali instruksi di bawah memintanya.`,
|
|
7961
|
+
CODE_STYLE_CLAUSE,
|
|
7962
|
+
`Worktree ini detached HEAD \u2014 memang disengaja. Bila kamu memang perlu meninggalkan perubahan berkas, commit dan katakan itu di ringkasan akhir; jangan gagal diam-diam.`,
|
|
7963
|
+
`Project ${project.id} \xB7 ${project.name}
|
|
7964
|
+
Deskripsi: ${project.desc || "\u2014"}
|
|
7965
|
+
Stack: ${project.stack || "\u2014"}`,
|
|
7966
|
+
`=== INSTRUKSI OPERATOR ===
|
|
7967
|
+
${cron.prompt}`
|
|
7968
|
+
].join("\n\n");
|
|
7969
|
+
}
|
|
7818
7970
|
var PIPELINES, AUTONOMY_CLAUSE, AUTONOMY_CLAUSE_FULL, autonomyClause, phaseInstruction, PHASE_SKILLS, skillInstruction, auditDecisionInstruction, ESCALATION_CONTRACT, auditOnlyInstruction, auditContinuationInstruction, writesCode, scopeClause, codeStyleClause, resumeClause, RESUMED_WORKTREE_NOTE, REVERSE_PHASE_GUIDE, SCAFFOLD_PHASE_GUIDE;
|
|
7819
7971
|
var init_prompt = __esm({
|
|
7820
7972
|
"../runner/src/prompt.ts"() {
|
|
@@ -19607,6 +19759,14 @@ async function recordLeadDecision(decisionId, title, projectId, specId, sessionI
|
|
|
19607
19759
|
}).catch(() => {
|
|
19608
19760
|
});
|
|
19609
19761
|
}
|
|
19762
|
+
async function recordCronRun(cronId, cronName, projectId, dueAt, status, note) {
|
|
19763
|
+
const verb = status === "launched" ? "berjalan" : status === "skipped" ? "dilewati" : "gagal";
|
|
19764
|
+
const title = `Cron "${cronName}" ${verb}${note ? ` \u2014 ${note}` : ""}`;
|
|
19765
|
+
await prisma.notification.create({
|
|
19766
|
+
data: { type: "cron", key: `cron:${cronId}:${dueAt.toISOString()}`, title, projectId }
|
|
19767
|
+
}).catch(() => {
|
|
19768
|
+
});
|
|
19769
|
+
}
|
|
19610
19770
|
var awaiting = /* @__PURE__ */ new Set();
|
|
19611
19771
|
async function scanDecisions(read = liveDecisions) {
|
|
19612
19772
|
const next = /* @__PURE__ */ new Set();
|
|
@@ -26540,6 +26700,104 @@ async function isQueued(id) {
|
|
|
26540
26700
|
return row?.status === "queued";
|
|
26541
26701
|
}
|
|
26542
26702
|
|
|
26703
|
+
// src/services/scheduler/cron.ts
|
|
26704
|
+
init_db();
|
|
26705
|
+
init_src();
|
|
26706
|
+
var GRACE_MS = 30 * 6e4;
|
|
26707
|
+
function computeNextRun(expr, after) {
|
|
26708
|
+
const spec = parseCron(expr);
|
|
26709
|
+
return spec ? nextRun(spec, after) : null;
|
|
26710
|
+
}
|
|
26711
|
+
function latestDue(expr, from, now) {
|
|
26712
|
+
let cursor = from;
|
|
26713
|
+
let due = null;
|
|
26714
|
+
let missed = -1;
|
|
26715
|
+
for (let i = 0; i < 2e4; i++) {
|
|
26716
|
+
const nxt = computeNextRun(expr, cursor);
|
|
26717
|
+
if (!nxt || nxt.getTime() > now) break;
|
|
26718
|
+
due = nxt;
|
|
26719
|
+
missed++;
|
|
26720
|
+
cursor = nxt;
|
|
26721
|
+
}
|
|
26722
|
+
return due ? { due, missed: Math.max(0, missed) } : null;
|
|
26723
|
+
}
|
|
26724
|
+
async function sweepCronDue(now) {
|
|
26725
|
+
const crons = await prisma.schedulerCron.findMany({
|
|
26726
|
+
where: { enabled: true, nextRunAt: { lte: new Date(now) } }
|
|
26727
|
+
});
|
|
26728
|
+
for (const cron of crons) await materialize(cron, now);
|
|
26729
|
+
await expireStale(now);
|
|
26730
|
+
}
|
|
26731
|
+
async function materialize(cron, now) {
|
|
26732
|
+
const from = new Date(cron.nextRunAt.getTime() - 1);
|
|
26733
|
+
const hit = latestDue(cron.expr, from, now);
|
|
26734
|
+
await prisma.schedulerCron.update({
|
|
26735
|
+
where: { id: cron.id },
|
|
26736
|
+
data: { nextRunAt: computeNextRun(cron.expr, new Date(now)) }
|
|
26737
|
+
});
|
|
26738
|
+
if (!hit) return;
|
|
26739
|
+
const late2 = now - hit.due.getTime() > GRACE_MS;
|
|
26740
|
+
const missedNote = hit.missed > 0 ? `terlewat ${hit.missed} jatuh tempo \u2014 scheduler tak berjalan` : "terlewat \u2014 scheduler tak berjalan saat jatuh tempo";
|
|
26741
|
+
try {
|
|
26742
|
+
await prisma.schedulerCronRun.create({
|
|
26743
|
+
data: {
|
|
26744
|
+
cronId: cron.id,
|
|
26745
|
+
projectId: cron.projectId,
|
|
26746
|
+
dueAt: hit.due,
|
|
26747
|
+
...late2 ? { status: "skipped", note: missedNote } : {}
|
|
26748
|
+
}
|
|
26749
|
+
});
|
|
26750
|
+
} catch {
|
|
26751
|
+
return;
|
|
26752
|
+
}
|
|
26753
|
+
if (late2) await recordCronRun(cron.id, cron.name, cron.projectId, hit.due, "skipped", missedNote);
|
|
26754
|
+
}
|
|
26755
|
+
async function expireStale(now) {
|
|
26756
|
+
const stale = await prisma.schedulerCronRun.findMany({
|
|
26757
|
+
where: { status: "queued", dueAt: { lt: new Date(now - GRACE_MS) } }
|
|
26758
|
+
});
|
|
26759
|
+
for (const run2 of stale) {
|
|
26760
|
+
const note = run2.note ?? "tak terluncurkan sampai batas keterlambatan";
|
|
26761
|
+
await prisma.schedulerCronRun.update({ where: { id: run2.id }, data: { status: "skipped", note } });
|
|
26762
|
+
const cron = await prisma.schedulerCron.findUnique({ where: { id: run2.cronId } });
|
|
26763
|
+
await recordCronRun(run2.cronId, cron?.name ?? run2.cronId, run2.projectId, run2.dueAt, "skipped", note);
|
|
26764
|
+
}
|
|
26765
|
+
}
|
|
26766
|
+
function queuedCronRuns() {
|
|
26767
|
+
return prisma.schedulerCronRun.findMany({ where: { status: "queued" }, orderBy: { dueAt: "asc" } });
|
|
26768
|
+
}
|
|
26769
|
+
async function markCronLaunched(id, sessionId2) {
|
|
26770
|
+
const { count } = await prisma.schedulerCronRun.updateMany({
|
|
26771
|
+
where: { id, status: "queued" },
|
|
26772
|
+
data: { status: "launched", sessionId: sessionId2, startedAt: /* @__PURE__ */ new Date(), note: null }
|
|
26773
|
+
});
|
|
26774
|
+
return count > 0;
|
|
26775
|
+
}
|
|
26776
|
+
async function markCronFailed(id, note) {
|
|
26777
|
+
await prisma.schedulerCronRun.updateMany({ where: { id, status: "queued" }, data: { status: "failed", note } });
|
|
26778
|
+
}
|
|
26779
|
+
async function markCronSkipped(id, note) {
|
|
26780
|
+
await prisma.schedulerCronRun.updateMany({ where: { id, status: "queued" }, data: { status: "skipped", note } });
|
|
26781
|
+
}
|
|
26782
|
+
async function noteCronRun(id, note) {
|
|
26783
|
+
const row = await prisma.schedulerCronRun.findUnique({ where: { id }, select: { note: true } });
|
|
26784
|
+
if (row?.note === note) return;
|
|
26785
|
+
await prisma.schedulerCronRun.updateMany({ where: { id, status: "queued" }, data: { note } });
|
|
26786
|
+
}
|
|
26787
|
+
async function listCronRunsPage(cronId, f = {}) {
|
|
26788
|
+
const where = { cronId };
|
|
26789
|
+
const total = await prisma.schedulerCronRun.count({ where });
|
|
26790
|
+
const pageSize = f.limit ? Math.max(1, Math.floor(+f.limit) || 1) : total || 1;
|
|
26791
|
+
const page = f.page ? Math.max(1, Math.floor(+f.page) || 1) : 1;
|
|
26792
|
+
const items = await prisma.schedulerCronRun.findMany({
|
|
26793
|
+
where,
|
|
26794
|
+
orderBy: { dueAt: "desc" },
|
|
26795
|
+
skip: (page - 1) * pageSize,
|
|
26796
|
+
take: pageSize
|
|
26797
|
+
});
|
|
26798
|
+
return { items, total, page, pageSize };
|
|
26799
|
+
}
|
|
26800
|
+
|
|
26543
26801
|
// src/services/scheduler/registry.ts
|
|
26544
26802
|
var sources = /* @__PURE__ */ new Map();
|
|
26545
26803
|
var lastRun = /* @__PURE__ */ new Map();
|
|
@@ -26634,6 +26892,127 @@ async function scheduler_default(app2) {
|
|
|
26634
26892
|
pageSize: r.pageSize
|
|
26635
26893
|
};
|
|
26636
26894
|
});
|
|
26895
|
+
const cronView = (c) => ({
|
|
26896
|
+
id: c.id,
|
|
26897
|
+
projectId: c.projectId,
|
|
26898
|
+
name: c.name,
|
|
26899
|
+
expr: c.expr,
|
|
26900
|
+
prompt: c.prompt,
|
|
26901
|
+
agent: c.agent,
|
|
26902
|
+
model: c.model,
|
|
26903
|
+
effort: c.effort,
|
|
26904
|
+
enabled: c.enabled,
|
|
26905
|
+
nextRunAt: c.nextRunAt ? c.nextRunAt.toISOString() : null,
|
|
26906
|
+
lastRunAt: c.lastRunAt ? c.lastRunAt.toISOString() : null,
|
|
26907
|
+
createdAt: c.createdAt.toISOString()
|
|
26908
|
+
});
|
|
26909
|
+
const runView = (r) => ({
|
|
26910
|
+
id: r.id,
|
|
26911
|
+
cronId: r.cronId,
|
|
26912
|
+
projectId: r.projectId,
|
|
26913
|
+
dueAt: r.dueAt.toISOString(),
|
|
26914
|
+
startedAt: r.startedAt ? r.startedAt.toISOString() : null,
|
|
26915
|
+
status: r.status,
|
|
26916
|
+
sessionId: r.sessionId,
|
|
26917
|
+
note: r.note,
|
|
26918
|
+
manual: r.manual,
|
|
26919
|
+
createdAt: r.createdAt.toISOString()
|
|
26920
|
+
});
|
|
26921
|
+
app2.get("/scheduler/crons", async (req) => {
|
|
26922
|
+
const { projectId, page, limit } = req.query;
|
|
26923
|
+
const where = projectId ? { projectId } : void 0;
|
|
26924
|
+
const total = await prisma.schedulerCron.count({ where });
|
|
26925
|
+
const pageSize = limit ? Math.max(1, Math.floor(+limit) || 1) : total || 1;
|
|
26926
|
+
const p = page ? Math.max(1, Math.floor(+page) || 1) : 1;
|
|
26927
|
+
const items = await prisma.schedulerCron.findMany({
|
|
26928
|
+
where,
|
|
26929
|
+
orderBy: { createdAt: "desc" },
|
|
26930
|
+
skip: (p - 1) * pageSize,
|
|
26931
|
+
take: pageSize
|
|
26932
|
+
});
|
|
26933
|
+
return { items: items.map(cronView), total, page: p, pageSize };
|
|
26934
|
+
});
|
|
26935
|
+
app2.post("/scheduler/crons", async (req, reply) => {
|
|
26936
|
+
const parsed = zCreateCron.safeParse(req.body);
|
|
26937
|
+
if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
|
|
26938
|
+
const d = parsed.data;
|
|
26939
|
+
if (!await prisma.project.findUnique({ where: { id: d.project } }))
|
|
26940
|
+
return reply.code(404).send({ error: "project not found" });
|
|
26941
|
+
const created = await prisma.schedulerCron.create({
|
|
26942
|
+
data: {
|
|
26943
|
+
projectId: d.project,
|
|
26944
|
+
name: d.name,
|
|
26945
|
+
expr: d.expr,
|
|
26946
|
+
prompt: d.prompt,
|
|
26947
|
+
agent: d.agent ?? null,
|
|
26948
|
+
model: d.model ?? null,
|
|
26949
|
+
effort: d.effort ?? null,
|
|
26950
|
+
enabled: d.enabled,
|
|
26951
|
+
// Dihitung sekarang walau cron-nya nonaktif: kolomnya juga yang memberi makan preview
|
|
26952
|
+
// "jalan berikutnya" di daftar, dan menghitungnya baru saat diaktifkan membuat baris yang
|
|
26953
|
+
// baru dibuat tampak tak berjadwal.
|
|
26954
|
+
nextRunAt: computeNextRun(d.expr, /* @__PURE__ */ new Date())
|
|
26955
|
+
}
|
|
26956
|
+
});
|
|
26957
|
+
return reply.code(201).send(cronView(created));
|
|
26958
|
+
});
|
|
26959
|
+
app2.patch("/scheduler/crons/:id", async (req, reply) => {
|
|
26960
|
+
const { id } = req.params;
|
|
26961
|
+
const parsed = zPatchCron.safeParse(req.body ?? {});
|
|
26962
|
+
if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
|
|
26963
|
+
const cur = await prisma.schedulerCron.findUnique({ where: { id } });
|
|
26964
|
+
if (!cur) return reply.code(404).send({ error: "cron tak ada" });
|
|
26965
|
+
const d = parsed.data;
|
|
26966
|
+
const recompute = d.expr !== void 0 || cur.nextRunAt === null;
|
|
26967
|
+
const updated = await prisma.schedulerCron.update({
|
|
26968
|
+
where: { id },
|
|
26969
|
+
data: {
|
|
26970
|
+
...d.name !== void 0 ? { name: d.name } : {},
|
|
26971
|
+
...d.expr !== void 0 ? { expr: d.expr } : {},
|
|
26972
|
+
...d.prompt !== void 0 ? { prompt: d.prompt } : {},
|
|
26973
|
+
...d.agent !== void 0 ? { agent: d.agent } : {},
|
|
26974
|
+
...d.model !== void 0 ? { model: d.model } : {},
|
|
26975
|
+
...d.effort !== void 0 ? { effort: d.effort } : {},
|
|
26976
|
+
...d.enabled !== void 0 ? { enabled: d.enabled } : {},
|
|
26977
|
+
...recompute ? { nextRunAt: computeNextRun(d.expr ?? cur.expr, /* @__PURE__ */ new Date()) } : {}
|
|
26978
|
+
}
|
|
26979
|
+
});
|
|
26980
|
+
return cronView(updated);
|
|
26981
|
+
});
|
|
26982
|
+
app2.delete("/scheduler/crons/:id", async (req, reply) => {
|
|
26983
|
+
const { id } = req.params;
|
|
26984
|
+
if (!await prisma.schedulerCron.findUnique({ where: { id } }))
|
|
26985
|
+
return reply.code(404).send({ error: "cron tak ada" });
|
|
26986
|
+
await prisma.schedulerCronRun.deleteMany({ where: { cronId: id } });
|
|
26987
|
+
await prisma.schedulerCron.delete({ where: { id } });
|
|
26988
|
+
return reply.code(204).send();
|
|
26989
|
+
});
|
|
26990
|
+
app2.post("/scheduler/crons/:id/run", async (req, reply) => {
|
|
26991
|
+
const { id } = req.params;
|
|
26992
|
+
const cron = await prisma.schedulerCron.findUnique({ where: { id } });
|
|
26993
|
+
if (!cron) return reply.code(404).send({ error: "cron tak ada" });
|
|
26994
|
+
const cfg = await getScheduler();
|
|
26995
|
+
if (!cfg.enabled) return reply.code(409).send({ error: "scheduler sedang berhenti \u2014 aktifkan dulu di panel Kendali" });
|
|
26996
|
+
if (cfg.paused) return reply.code(409).send({ error: "scheduler sedang dijeda \u2014 lanjutkan dulu di panel Kendali" });
|
|
26997
|
+
const pending = await prisma.schedulerCronRun.findFirst({ where: { cronId: id, status: "queued" } });
|
|
26998
|
+
if (pending) return reply.code(409).send({ error: "masih ada run yang menunggu dijalankan", runId: pending.id });
|
|
26999
|
+
try {
|
|
27000
|
+
const run2 = await prisma.schedulerCronRun.create({
|
|
27001
|
+
data: { cronId: id, projectId: cron.projectId, dueAt: /* @__PURE__ */ new Date(), manual: true }
|
|
27002
|
+
});
|
|
27003
|
+
return reply.code(201).send(runView(run2));
|
|
27004
|
+
} catch {
|
|
27005
|
+
return reply.code(409).send({ error: "run untuk jatuh tempo ini sudah ada" });
|
|
27006
|
+
}
|
|
27007
|
+
});
|
|
27008
|
+
app2.get("/scheduler/crons/:id/runs", async (req, reply) => {
|
|
27009
|
+
const { id } = req.params;
|
|
27010
|
+
if (!await prisma.schedulerCron.findUnique({ where: { id } }))
|
|
27011
|
+
return reply.code(404).send({ error: "cron tak ada" });
|
|
27012
|
+
const { page, limit } = req.query;
|
|
27013
|
+
const r = await listCronRunsPage(id, { page, limit });
|
|
27014
|
+
return { items: r.items.map(runView), total: r.total, page: r.page, pageSize: r.pageSize };
|
|
27015
|
+
});
|
|
26637
27016
|
}
|
|
26638
27017
|
|
|
26639
27018
|
// src/routes/lead.ts
|
|
@@ -30123,8 +30502,9 @@ async function specStages(ids) {
|
|
|
30123
30502
|
async function portal_default(app2) {
|
|
30124
30503
|
app2.get("/portal/projects", async (req) => {
|
|
30125
30504
|
const ids = await clientProjectIds(req.user.id);
|
|
30505
|
+
const { page, limit } = req.query;
|
|
30126
30506
|
const rows = await prisma.project.findMany({ where: { id: { in: ids } }, orderBy: { name: "asc" } });
|
|
30127
|
-
return
|
|
30507
|
+
return paginate(rows.map(toPortalProject), page, limit);
|
|
30128
30508
|
});
|
|
30129
30509
|
app2.get("/portal/projects/:id/backlog", async (req, reply) => {
|
|
30130
30510
|
const { id } = req.params;
|
|
@@ -30484,7 +30864,10 @@ function capabilityForRoute(method, path) {
|
|
|
30484
30864
|
if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync" || top === "portal" || top === "client-accounts") return "COOKIE_ONLY";
|
|
30485
30865
|
if (top === "limits" || top === "update" || top === "events" || top === "fs" || top === "health")
|
|
30486
30866
|
return read ? "GLOBAL_READ" : "COOKIE_ONLY";
|
|
30487
|
-
if (top === "scheduler")
|
|
30867
|
+
if (top === "scheduler") {
|
|
30868
|
+
if (seg[1] === "crons") return "COOKIE_ONLY";
|
|
30869
|
+
return rw("settings");
|
|
30870
|
+
}
|
|
30488
30871
|
if (top === "lead") return rw("lead");
|
|
30489
30872
|
if (top === "custom-agents") return rw("agents");
|
|
30490
30873
|
if (top === "telegram") {
|
|
@@ -30791,14 +31174,69 @@ init_db();
|
|
|
30791
31174
|
init_src();
|
|
30792
31175
|
|
|
30793
31176
|
// src/services/scheduler/governor.ts
|
|
31177
|
+
init_db();
|
|
30794
31178
|
var ALREADY_DONE_NOTE = "spec sudah selesai \u2014 tak diluncurkan";
|
|
30795
31179
|
var canceledRaceNote = (sessionId2) => `dibatalkan saat sesi ${sessionId2} sudah terlanjur lahir \u2014 sesi dibiarkan hidup, tutup dari Terminal bila tak diperlukan`;
|
|
31180
|
+
var CAP_FULL_NOTE = "cap penuh \u2014 tak ada slot sesi";
|
|
31181
|
+
var cronLiveNote = (sessionId2) => `sesi cron sebelumnya masih berjalan (${sessionId2})`;
|
|
31182
|
+
var CRON_GONE_NOTE = "cron sudah dihapus atau dinonaktifkan";
|
|
31183
|
+
var CRON_OPTOUT_NOTE = "project belum di-opt-in scheduler";
|
|
31184
|
+
async function drainCronRuns(slots, deps) {
|
|
31185
|
+
for (const run2 of await queuedCronRuns()) {
|
|
31186
|
+
const cron = await prisma.schedulerCron.findUnique({ where: { id: run2.cronId } });
|
|
31187
|
+
const close = async (status, note) => {
|
|
31188
|
+
if (status === "skipped") await markCronSkipped(run2.id, note);
|
|
31189
|
+
else await markCronFailed(run2.id, note);
|
|
31190
|
+
await recordCronRun(run2.cronId, cron?.name ?? run2.cronId, run2.projectId, run2.dueAt, status, note);
|
|
31191
|
+
};
|
|
31192
|
+
if (!cron || !cron.enabled) {
|
|
31193
|
+
await close("skipped", CRON_GONE_NOTE);
|
|
31194
|
+
continue;
|
|
31195
|
+
}
|
|
31196
|
+
const project = await prisma.project.findUnique({
|
|
31197
|
+
where: { id: cron.projectId },
|
|
31198
|
+
select: { schedulerOptIn: true }
|
|
31199
|
+
});
|
|
31200
|
+
if (!project?.schedulerOptIn) {
|
|
31201
|
+
await close("skipped", CRON_OPTOUT_NOTE);
|
|
31202
|
+
continue;
|
|
31203
|
+
}
|
|
31204
|
+
const live = deps.liveCron(cron.id);
|
|
31205
|
+
if (live) {
|
|
31206
|
+
await close("skipped", cronLiveNote(live));
|
|
31207
|
+
continue;
|
|
31208
|
+
}
|
|
31209
|
+
if (slots <= 0) {
|
|
31210
|
+
await noteCronRun(run2.id, CAP_FULL_NOTE);
|
|
31211
|
+
continue;
|
|
31212
|
+
}
|
|
31213
|
+
try {
|
|
31214
|
+
const sessionId2 = await deps.launchCron({
|
|
31215
|
+
id: cron.id,
|
|
31216
|
+
projectId: cron.projectId,
|
|
31217
|
+
name: cron.name,
|
|
31218
|
+
prompt: cron.prompt,
|
|
31219
|
+
agent: cron.agent,
|
|
31220
|
+
model: cron.model,
|
|
31221
|
+
effort: cron.effort
|
|
31222
|
+
});
|
|
31223
|
+
if (await markCronLaunched(run2.id, sessionId2))
|
|
31224
|
+
await recordCronRun(cron.id, cron.name, cron.projectId, run2.dueAt, "launched", null);
|
|
31225
|
+
await prisma.schedulerCron.update({ where: { id: cron.id }, data: { lastRunAt: /* @__PURE__ */ new Date() } });
|
|
31226
|
+
slots--;
|
|
31227
|
+
} catch (e) {
|
|
31228
|
+
await close("failed", e.message);
|
|
31229
|
+
}
|
|
31230
|
+
}
|
|
31231
|
+
return slots;
|
|
31232
|
+
}
|
|
30796
31233
|
var draining = false;
|
|
30797
31234
|
async function drain(cfg, deps) {
|
|
30798
31235
|
if (draining) return;
|
|
30799
31236
|
draining = true;
|
|
30800
31237
|
try {
|
|
30801
31238
|
let slots = cfg.maxConcurrent - deps.liveCount();
|
|
31239
|
+
slots = await deps.drainCrons(slots);
|
|
30802
31240
|
if (slots <= 0) return;
|
|
30803
31241
|
for (const item of await queued2()) {
|
|
30804
31242
|
if (slots <= 0) break;
|
|
@@ -30830,6 +31268,44 @@ async function drain(cfg, deps) {
|
|
|
30830
31268
|
}
|
|
30831
31269
|
}
|
|
30832
31270
|
|
|
31271
|
+
// src/services/scheduler/cron-session.ts
|
|
31272
|
+
init_db();
|
|
31273
|
+
init_src2();
|
|
31274
|
+
init_settings2();
|
|
31275
|
+
init_codex_trust();
|
|
31276
|
+
init_pty();
|
|
31277
|
+
var cronSessionId = (cronId) => `cron-${cronId.toLowerCase().replace(/[^a-z0-9_-]/g, "_")}`;
|
|
31278
|
+
function liveCronSession(cronId) {
|
|
31279
|
+
const s2 = getSession(cronSessionId(cronId));
|
|
31280
|
+
return s2 && !s2.exited ? s2.id : null;
|
|
31281
|
+
}
|
|
31282
|
+
async function startCronSession(cron) {
|
|
31283
|
+
const repoDir = await resolveRepoDir(cron.projectId);
|
|
31284
|
+
if (!repoDir) throw new Error(`project "${cron.projectId}" belum di-bind ke checkout lokal`);
|
|
31285
|
+
const project = await prisma.project.findUnique({ where: { id: cron.projectId } });
|
|
31286
|
+
if (!project) throw new Error(`project "${cron.projectId}" tak ada`);
|
|
31287
|
+
const { agent, model, effort } = await terminalAgentDefaults({
|
|
31288
|
+
agent: cron.agent ?? void 0,
|
|
31289
|
+
model: cron.model ?? void 0,
|
|
31290
|
+
effort: cron.effort ?? void 0
|
|
31291
|
+
});
|
|
31292
|
+
if (agent === "codex") ensureCodexTrust(repoDir);
|
|
31293
|
+
const id = cronSessionId(cron.id);
|
|
31294
|
+
const wt = `${repoDir}/.worktrees/${id}`;
|
|
31295
|
+
if (!realGit.worktreeAlive(wt)) realGit.addWorktree(repoDir, wt, "HEAD");
|
|
31296
|
+
const s2 = createSession(cron.projectId, wt, {
|
|
31297
|
+
id,
|
|
31298
|
+
agent,
|
|
31299
|
+
model,
|
|
31300
|
+
effort,
|
|
31301
|
+
prompt: cronPrompt(
|
|
31302
|
+
{ id: project.id, name: project.name, desc: project.desc, stack: project.stack },
|
|
31303
|
+
{ name: cron.name, prompt: cron.prompt }
|
|
31304
|
+
)
|
|
31305
|
+
});
|
|
31306
|
+
return { id: s2.id };
|
|
31307
|
+
}
|
|
31308
|
+
|
|
30833
31309
|
// src/services/scheduler/reconcile.ts
|
|
30834
31310
|
init_db();
|
|
30835
31311
|
init_src2();
|
|
@@ -30922,6 +31398,11 @@ async function tick3(now, deps, end2 = prodEnd) {
|
|
|
30922
31398
|
await end2.scanDecisions();
|
|
30923
31399
|
} catch {
|
|
30924
31400
|
}
|
|
31401
|
+
try {
|
|
31402
|
+
await sweepCronDue(now);
|
|
31403
|
+
} catch (e) {
|
|
31404
|
+
console.error("scheduler cron sweep:", e);
|
|
31405
|
+
}
|
|
30925
31406
|
if (cfg.paused) return;
|
|
30926
31407
|
try {
|
|
30927
31408
|
await drain(cfg, deps);
|
|
@@ -30954,7 +31435,12 @@ var prodDeps = {
|
|
|
30954
31435
|
if (!spec) throw new Error(`spec ${item.specId} tak ada`);
|
|
30955
31436
|
const r = await startSpecSession(spec, { flow: flowForSource(spec.source), autonomy });
|
|
30956
31437
|
return r.id;
|
|
30957
|
-
}
|
|
31438
|
+
},
|
|
31439
|
+
// SPEC-646 · ADR-0112 · cron memakai anggaran slot yang sama dengan antrean spec.
|
|
31440
|
+
drainCrons: (slots) => drainCronRuns(slots, {
|
|
31441
|
+
liveCron: liveCronSession,
|
|
31442
|
+
launchCron: async (cron) => (await startCronSession(cron)).id
|
|
31443
|
+
})
|
|
30958
31444
|
};
|
|
30959
31445
|
var TICK_MS2 = 1e4;
|
|
30960
31446
|
var timer4;
|
package/package.json
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- SPEC-646 · ADR-0112 · cronjob per project (LOCAL-ONLY, aditif murni).
|
|
2
|
+
CREATE TABLE "SchedulerCron" (
|
|
3
|
+
"id" TEXT NOT NULL PRIMARY KEY,
|
|
4
|
+
"projectId" TEXT NOT NULL,
|
|
5
|
+
"name" TEXT NOT NULL,
|
|
6
|
+
"expr" TEXT NOT NULL,
|
|
7
|
+
"prompt" TEXT NOT NULL,
|
|
8
|
+
"agent" TEXT,
|
|
9
|
+
"model" TEXT,
|
|
10
|
+
"effort" TEXT,
|
|
11
|
+
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
|
12
|
+
"nextRunAt" DATETIME,
|
|
13
|
+
"lastRunAt" DATETIME,
|
|
14
|
+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
15
|
+
"updatedAt" DATETIME NOT NULL
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX "SchedulerCron_projectId_idx" ON "SchedulerCron"("projectId");
|
|
18
|
+
CREATE INDEX "SchedulerCron_enabled_idx" ON "SchedulerCron"("enabled");
|
|
19
|
+
|
|
20
|
+
CREATE TABLE "SchedulerCronRun" (
|
|
21
|
+
"id" TEXT NOT NULL PRIMARY KEY,
|
|
22
|
+
"cronId" TEXT NOT NULL,
|
|
23
|
+
"projectId" TEXT NOT NULL,
|
|
24
|
+
"dueAt" DATETIME NOT NULL,
|
|
25
|
+
"startedAt" DATETIME,
|
|
26
|
+
"status" TEXT NOT NULL DEFAULT 'queued',
|
|
27
|
+
"sessionId" TEXT,
|
|
28
|
+
"note" TEXT,
|
|
29
|
+
"manual" BOOLEAN NOT NULL DEFAULT false,
|
|
30
|
+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
31
|
+
);
|
|
32
|
+
CREATE UNIQUE INDEX "SchedulerCronRun_cronId_dueAt_key" ON "SchedulerCronRun"("cronId", "dueAt");
|
|
33
|
+
CREATE INDEX "SchedulerCronRun_cronId_dueAt_idx" ON "SchedulerCronRun"("cronId", "dueAt");
|
|
34
|
+
CREATE INDEX "SchedulerCronRun_status_idx" ON "SchedulerCronRun"("status");
|