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/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 { items: rows.map(toPortalProject) };
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") return rw("settings");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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");