hanoman 0.1.24 → 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 +589 -35
- 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-DqY-l_Cl.js → index-BfOACraN.js} +1547 -1547
- 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();
|
|
@@ -26094,10 +26254,64 @@ async function pruneOldTickets(now = Date.now()) {
|
|
|
26094
26254
|
}
|
|
26095
26255
|
}
|
|
26096
26256
|
|
|
26257
|
+
// src/services/ticket-intake.ts
|
|
26258
|
+
init_db();
|
|
26259
|
+
var TICKET_UPLOAD = {
|
|
26260
|
+
MAX_FILES: 3,
|
|
26261
|
+
MAX_BYTES: 5 * 1024 * 1024,
|
|
26262
|
+
OK_MIME: /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"])
|
|
26263
|
+
};
|
|
26264
|
+
async function parseTicketUpload(req) {
|
|
26265
|
+
const fields = {};
|
|
26266
|
+
const files = [];
|
|
26267
|
+
try {
|
|
26268
|
+
for await (const part of req.parts()) {
|
|
26269
|
+
if (part.type === "file") {
|
|
26270
|
+
const buf = await part.toBuffer();
|
|
26271
|
+
if (part.file?.truncated || !TICKET_UPLOAD.OK_MIME.has(part.mimetype) || files.length >= TICKET_UPLOAD.MAX_FILES) continue;
|
|
26272
|
+
files.push({ buf, mime: part.mimetype, name: String(part.filename ?? "gambar") });
|
|
26273
|
+
} else {
|
|
26274
|
+
fields[part.fieldname] = String(part.value ?? "");
|
|
26275
|
+
}
|
|
26276
|
+
}
|
|
26277
|
+
} catch {
|
|
26278
|
+
return null;
|
|
26279
|
+
}
|
|
26280
|
+
return { fields, files };
|
|
26281
|
+
}
|
|
26282
|
+
async function intakeTicket(input) {
|
|
26283
|
+
const { ticket, key } = await createTicket({
|
|
26284
|
+
projectId: input.projectId,
|
|
26285
|
+
category: input.category,
|
|
26286
|
+
title: input.title,
|
|
26287
|
+
detail: input.detail,
|
|
26288
|
+
reporterEmail: input.reporterEmail
|
|
26289
|
+
});
|
|
26290
|
+
await notifySynced("ticket", ticket.id);
|
|
26291
|
+
for (const f of input.files) {
|
|
26292
|
+
const { storageKey, size } = await saveUpload(f.buf, f.mime);
|
|
26293
|
+
const att = await prisma.ticketAttachment.create({
|
|
26294
|
+
data: {
|
|
26295
|
+
ticketId: ticket.id,
|
|
26296
|
+
projectId: input.projectId,
|
|
26297
|
+
filename: f.name.slice(0, 200),
|
|
26298
|
+
mimeType: f.mime,
|
|
26299
|
+
size,
|
|
26300
|
+
storageKey
|
|
26301
|
+
}
|
|
26302
|
+
});
|
|
26303
|
+
await notifySynced("ticketAttachment", att.id);
|
|
26304
|
+
}
|
|
26305
|
+
await recordNewTicket(ticket.id, input.projectId, input.projectName, input.category, input.title);
|
|
26306
|
+
void pruneOldTickets();
|
|
26307
|
+
return { ticket, key };
|
|
26308
|
+
}
|
|
26309
|
+
|
|
26097
26310
|
// src/services/help-ratelimit.ts
|
|
26098
26311
|
init_config2();
|
|
26099
26312
|
var ipBuckets = /* @__PURE__ */ new Map();
|
|
26100
26313
|
var projBuckets = /* @__PURE__ */ new Map();
|
|
26314
|
+
var acctBuckets = /* @__PURE__ */ new Map();
|
|
26101
26315
|
function take(map, k, cap, now) {
|
|
26102
26316
|
const b = map.get(k) ?? { tokens: cap, ts: now };
|
|
26103
26317
|
b.tokens = Math.min(cap, b.tokens + (now - b.ts) * cap / 6e4);
|
|
@@ -26115,13 +26329,15 @@ function helpRateOk(projectId, ip, now = Date.now()) {
|
|
|
26115
26329
|
const projCap = effectiveInt("HANOMAN_HELP_RATE_PER_MIN_PROJECT") ?? 20;
|
|
26116
26330
|
return take(ipBuckets, ip, ipCap, now) && take(projBuckets, projectId, projCap, now);
|
|
26117
26331
|
}
|
|
26332
|
+
function portalTicketRateOk(userId, projectId, now = Date.now()) {
|
|
26333
|
+
const acctCap = effectiveInt("HANOMAN_PORTAL_TICKET_RATE_PER_MIN") ?? 5;
|
|
26334
|
+
const projCap = effectiveInt("HANOMAN_HELP_RATE_PER_MIN_PROJECT") ?? 20;
|
|
26335
|
+
return take(acctBuckets, userId, acctCap, now) && take(projBuckets, projectId, projCap, now);
|
|
26336
|
+
}
|
|
26118
26337
|
|
|
26119
26338
|
// src/routes/help.ts
|
|
26120
26339
|
var CATEGORIES = ["bug", "fitur", "pertanyaan", "lainnya"];
|
|
26121
26340
|
var HONEYPOT_FIELD = "hc_trap";
|
|
26122
|
-
var OK_MIME = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
26123
|
-
var MAX_FILES2 = 3;
|
|
26124
|
-
var MAX_BYTES = 5 * 1024 * 1024;
|
|
26125
26341
|
var zField = external_exports.object({
|
|
26126
26342
|
category: zTicketCategory,
|
|
26127
26343
|
title: external_exports.string().min(1).max(200),
|
|
@@ -26141,21 +26357,9 @@ async function help_default(app2) {
|
|
|
26141
26357
|
if (!p || !p.helpEnabled) return reply.code(404).send({ error: "not found" });
|
|
26142
26358
|
if (!helpRateOk(slug, req.ip)) return reply.code(429).send({ error: "terlalu banyak permintaan" });
|
|
26143
26359
|
if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
|
|
26144
|
-
const
|
|
26145
|
-
|
|
26146
|
-
|
|
26147
|
-
for await (const part of req.parts()) {
|
|
26148
|
-
if (part.type === "file") {
|
|
26149
|
-
const buf = await part.toBuffer();
|
|
26150
|
-
if (part.file?.truncated || !OK_MIME.has(part.mimetype) || files.length >= MAX_FILES2) continue;
|
|
26151
|
-
files.push({ buf, mime: part.mimetype, name: String(part.filename ?? "gambar") });
|
|
26152
|
-
} else {
|
|
26153
|
-
fields[part.fieldname] = String(part.value ?? "");
|
|
26154
|
-
}
|
|
26155
|
-
}
|
|
26156
|
-
} catch {
|
|
26157
|
-
return reply.code(400).send({ error: "unggahan tak valid" });
|
|
26158
|
-
}
|
|
26360
|
+
const upload = await parseTicketUpload(req);
|
|
26361
|
+
if (!upload) return reply.code(400).send({ error: "unggahan tak valid" });
|
|
26362
|
+
const { fields, files } = upload;
|
|
26159
26363
|
if (fields[HONEYPOT_FIELD]) {
|
|
26160
26364
|
console.warn(`help: honeypot terpicu \xB7 project=${slug} ip=${req.ip}`);
|
|
26161
26365
|
return reply.code(200).send({ ok: true });
|
|
@@ -26167,23 +26371,15 @@ async function help_default(app2) {
|
|
|
26167
26371
|
email: fields.email
|
|
26168
26372
|
});
|
|
26169
26373
|
if (!parsed.success) return reply.code(400).send({ error: "field wajib tak lengkap / tak valid" });
|
|
26170
|
-
const { ticket, key } = await
|
|
26374
|
+
const { ticket, key } = await intakeTicket({
|
|
26171
26375
|
projectId: slug,
|
|
26376
|
+
projectName: p.name,
|
|
26172
26377
|
category: parsed.data.category,
|
|
26173
26378
|
title: parsed.data.title,
|
|
26174
26379
|
detail: parsed.data.detail,
|
|
26175
|
-
reporterEmail: parsed.data.email
|
|
26380
|
+
reporterEmail: parsed.data.email,
|
|
26381
|
+
files
|
|
26176
26382
|
});
|
|
26177
|
-
await notifySynced("ticket", ticket.id);
|
|
26178
|
-
for (const f of files) {
|
|
26179
|
-
const { storageKey, size } = await saveUpload(f.buf, f.mime);
|
|
26180
|
-
const att = await prisma.ticketAttachment.create({
|
|
26181
|
-
data: { ticketId: ticket.id, projectId: slug, filename: f.name.slice(0, 200), mimeType: f.mime, size, storageKey }
|
|
26182
|
-
});
|
|
26183
|
-
await notifySynced("ticketAttachment", att.id);
|
|
26184
|
-
}
|
|
26185
|
-
await recordNewTicket(ticket.id, slug, p.name, parsed.data.category, parsed.data.title);
|
|
26186
|
-
void pruneOldTickets();
|
|
26187
26383
|
const statusPath = `/help/${encodeURIComponent(slug)}/status/${encodeURIComponent(key)}`;
|
|
26188
26384
|
return reply.code(201).send({ number: ticket.number, key, statusPath });
|
|
26189
26385
|
});
|
|
@@ -26504,6 +26700,104 @@ async function isQueued(id) {
|
|
|
26504
26700
|
return row?.status === "queued";
|
|
26505
26701
|
}
|
|
26506
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
|
+
|
|
26507
26801
|
// src/services/scheduler/registry.ts
|
|
26508
26802
|
var sources = /* @__PURE__ */ new Map();
|
|
26509
26803
|
var lastRun = /* @__PURE__ */ new Map();
|
|
@@ -26598,6 +26892,127 @@ async function scheduler_default(app2) {
|
|
|
26598
26892
|
pageSize: r.pageSize
|
|
26599
26893
|
};
|
|
26600
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
|
+
});
|
|
26601
27016
|
}
|
|
26602
27017
|
|
|
26603
27018
|
// src/routes/lead.ts
|
|
@@ -30038,18 +30453,20 @@ async function webhooks_default(app2) {
|
|
|
30038
30453
|
}
|
|
30039
30454
|
|
|
30040
30455
|
// src/routes/portal.ts
|
|
30456
|
+
init_zod();
|
|
30041
30457
|
init_src();
|
|
30042
30458
|
init_db();
|
|
30043
30459
|
|
|
30044
30460
|
// src/services/client-access.ts
|
|
30045
30461
|
init_db();
|
|
30046
30462
|
var segments = (path) => path.replace(/^\/api\/?/, "").replace(/\/+$/, "").split("/").filter(Boolean);
|
|
30463
|
+
var isPortalTicketSubmit = (method, seg) => method === "POST" && seg.length === 4 && seg[1] === "projects" && seg[3] === "tickets";
|
|
30047
30464
|
function clientRouteAllowed(method, path) {
|
|
30048
30465
|
const seg = segments(path);
|
|
30049
30466
|
if (seg.includes("..")) return false;
|
|
30050
30467
|
const top = seg[0] ?? "";
|
|
30051
30468
|
const read = method === "GET" || method === "HEAD";
|
|
30052
|
-
if (top === "portal") return read;
|
|
30469
|
+
if (top === "portal") return read || isPortalTicketSubmit(method, seg);
|
|
30053
30470
|
if (top === "help") return true;
|
|
30054
30471
|
if (top === "auth") {
|
|
30055
30472
|
const sub = seg[1] ?? "";
|
|
@@ -30071,6 +30488,11 @@ async function hasProjectAccess(userId, projectId) {
|
|
|
30071
30488
|
|
|
30072
30489
|
// src/routes/portal.ts
|
|
30073
30490
|
var NOT_FOUND = { error: "not found" };
|
|
30491
|
+
var zPortalTicket = external_exports.object({
|
|
30492
|
+
category: zTicketCategory,
|
|
30493
|
+
title: external_exports.string().min(1).max(200),
|
|
30494
|
+
detail: external_exports.string().min(1).max(1e4)
|
|
30495
|
+
});
|
|
30074
30496
|
async function specStages(ids) {
|
|
30075
30497
|
const wanted = [...new Set(ids.filter((v) => !!v))];
|
|
30076
30498
|
if (!wanted.length) return /* @__PURE__ */ new Map();
|
|
@@ -30080,8 +30502,9 @@ async function specStages(ids) {
|
|
|
30080
30502
|
async function portal_default(app2) {
|
|
30081
30503
|
app2.get("/portal/projects", async (req) => {
|
|
30082
30504
|
const ids = await clientProjectIds(req.user.id);
|
|
30505
|
+
const { page, limit } = req.query;
|
|
30083
30506
|
const rows = await prisma.project.findMany({ where: { id: { in: ids } }, orderBy: { name: "asc" } });
|
|
30084
|
-
return
|
|
30507
|
+
return paginate(rows.map(toPortalProject), page, limit);
|
|
30085
30508
|
});
|
|
30086
30509
|
app2.get("/portal/projects/:id/backlog", async (req, reply) => {
|
|
30087
30510
|
const { id } = req.params;
|
|
@@ -30113,6 +30536,31 @@ async function portal_default(app2) {
|
|
|
30113
30536
|
const stages = await specStages([t.specId]);
|
|
30114
30537
|
return toPortalTicketDetail(t, stages.get(t.specId ?? "") ?? null);
|
|
30115
30538
|
});
|
|
30539
|
+
app2.post("/portal/projects/:id/tickets", async (req, reply) => {
|
|
30540
|
+
const { id } = req.params;
|
|
30541
|
+
const p = await prisma.project.findUnique({ where: { id } });
|
|
30542
|
+
if (!p || !await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
|
|
30543
|
+
if (!portalTicketRateOk(req.user.id, id)) return reply.code(429).send({ error: "terlalu banyak permintaan" });
|
|
30544
|
+
if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
|
|
30545
|
+
const upload = await parseTicketUpload(req);
|
|
30546
|
+
if (!upload) return reply.code(400).send({ error: "unggahan tak valid" });
|
|
30547
|
+
const parsed = zPortalTicket.safeParse({
|
|
30548
|
+
category: upload.fields.category,
|
|
30549
|
+
title: upload.fields.title,
|
|
30550
|
+
detail: upload.fields.detail
|
|
30551
|
+
});
|
|
30552
|
+
if (!parsed.success) return reply.code(400).send({ error: "field wajib tak lengkap / tak valid" });
|
|
30553
|
+
const { ticket } = await intakeTicket({
|
|
30554
|
+
projectId: id,
|
|
30555
|
+
projectName: p.name,
|
|
30556
|
+
category: parsed.data.category,
|
|
30557
|
+
title: parsed.data.title,
|
|
30558
|
+
detail: parsed.data.detail,
|
|
30559
|
+
reporterEmail: req.user.email,
|
|
30560
|
+
files: upload.files
|
|
30561
|
+
});
|
|
30562
|
+
return reply.code(201).send(toPortalTicket(ticket, null));
|
|
30563
|
+
});
|
|
30116
30564
|
}
|
|
30117
30565
|
|
|
30118
30566
|
// src/routes/client-accounts.ts
|
|
@@ -30416,7 +30864,10 @@ function capabilityForRoute(method, path) {
|
|
|
30416
30864
|
if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync" || top === "portal" || top === "client-accounts") return "COOKIE_ONLY";
|
|
30417
30865
|
if (top === "limits" || top === "update" || top === "events" || top === "fs" || top === "health")
|
|
30418
30866
|
return read ? "GLOBAL_READ" : "COOKIE_ONLY";
|
|
30419
|
-
if (top === "scheduler")
|
|
30867
|
+
if (top === "scheduler") {
|
|
30868
|
+
if (seg[1] === "crons") return "COOKIE_ONLY";
|
|
30869
|
+
return rw("settings");
|
|
30870
|
+
}
|
|
30420
30871
|
if (top === "lead") return rw("lead");
|
|
30421
30872
|
if (top === "custom-agents") return rw("agents");
|
|
30422
30873
|
if (top === "telegram") {
|
|
@@ -30723,14 +31174,69 @@ init_db();
|
|
|
30723
31174
|
init_src();
|
|
30724
31175
|
|
|
30725
31176
|
// src/services/scheduler/governor.ts
|
|
31177
|
+
init_db();
|
|
30726
31178
|
var ALREADY_DONE_NOTE = "spec sudah selesai \u2014 tak diluncurkan";
|
|
30727
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
|
+
}
|
|
30728
31233
|
var draining = false;
|
|
30729
31234
|
async function drain(cfg, deps) {
|
|
30730
31235
|
if (draining) return;
|
|
30731
31236
|
draining = true;
|
|
30732
31237
|
try {
|
|
30733
31238
|
let slots = cfg.maxConcurrent - deps.liveCount();
|
|
31239
|
+
slots = await deps.drainCrons(slots);
|
|
30734
31240
|
if (slots <= 0) return;
|
|
30735
31241
|
for (const item of await queued2()) {
|
|
30736
31242
|
if (slots <= 0) break;
|
|
@@ -30762,6 +31268,44 @@ async function drain(cfg, deps) {
|
|
|
30762
31268
|
}
|
|
30763
31269
|
}
|
|
30764
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
|
+
|
|
30765
31309
|
// src/services/scheduler/reconcile.ts
|
|
30766
31310
|
init_db();
|
|
30767
31311
|
init_src2();
|
|
@@ -30854,6 +31398,11 @@ async function tick3(now, deps, end2 = prodEnd) {
|
|
|
30854
31398
|
await end2.scanDecisions();
|
|
30855
31399
|
} catch {
|
|
30856
31400
|
}
|
|
31401
|
+
try {
|
|
31402
|
+
await sweepCronDue(now);
|
|
31403
|
+
} catch (e) {
|
|
31404
|
+
console.error("scheduler cron sweep:", e);
|
|
31405
|
+
}
|
|
30857
31406
|
if (cfg.paused) return;
|
|
30858
31407
|
try {
|
|
30859
31408
|
await drain(cfg, deps);
|
|
@@ -30886,7 +31435,12 @@ var prodDeps = {
|
|
|
30886
31435
|
if (!spec) throw new Error(`spec ${item.specId} tak ada`);
|
|
30887
31436
|
const r = await startSpecSession(spec, { flow: flowForSource(spec.source), autonomy });
|
|
30888
31437
|
return r.id;
|
|
30889
|
-
}
|
|
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
|
+
})
|
|
30890
31444
|
};
|
|
30891
31445
|
var TICK_MS2 = 1e4;
|
|
30892
31446
|
var timer4;
|