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/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 fields = {};
26145
- const files = [];
26146
- try {
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 createTicket({
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 { items: rows.map(toPortalProject) };
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") return rw("settings");
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;