agentlas 1.0.12 → 1.0.14

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +4 -2
  3. package/bin/agentlas.cjs +17 -3
  4. package/engine/agentlas-config.cjs +25 -18
  5. package/engine/agentlas-core-harness.cjs +14 -1
  6. package/engine/agentlas-i18n.cjs +2 -0
  7. package/engine/agentlas-input.cjs +1 -1
  8. package/engine/agentlas-memory-governance.cjs +10 -0
  9. package/engine/agentlas-onboard.cjs +22 -5
  10. package/engine/agentlas-sqlite-policy.cjs +18 -7
  11. package/engine/agentlas-workforce.cjs +136 -19
  12. package/engine/agentlas-workload-routing.cjs +32 -4
  13. package/engine/agentlas.cjs +8 -0
  14. package/engine/automation/daemon.cjs +67 -30
  15. package/engine/automation/schedule.cjs +16 -0
  16. package/engine/automation/store.cjs +70 -12
  17. package/engine/bootstrap-schema.sql +788 -53
  18. package/engine/commands/automation.cjs +8 -0
  19. package/engine/commands/chats.cjs +6 -1
  20. package/engine/commands/firm.cjs +7 -0
  21. package/engine/commands/help.cjs +23 -2
  22. package/engine/commands/hep-cloud.cjs +31 -0
  23. package/engine/commands/hep-hub.cjs +30 -0
  24. package/engine/commands/hep-local.cjs +32 -0
  25. package/engine/commands/hep-network.cjs +43 -0
  26. package/engine/commands/index.cjs +38 -7
  27. package/engine/commands/open.cjs +5 -1
  28. package/engine/commands/run.cjs +12 -2
  29. package/engine/commands/setup.cjs +12 -11
  30. package/engine/commands/storm.cjs +5 -9
  31. package/engine/commands/swarm.cjs +4 -4
  32. package/engine/commands/uninstall.cjs +36 -2
  33. package/engine/commands/version.cjs +29 -0
  34. package/engine/commands/workforce.cjs +10 -10
  35. package/engine/core/schema-ensure.cjs +75 -0
  36. package/engine/experience/variant.cjs +46 -2
  37. package/engine/hephaestus/runtime.cjs +7 -0
  38. package/engine/memory-cli/curate.cjs +3 -7
  39. package/engine/project/memory-context.cjs +3 -7
  40. package/engine/project/state.cjs +7 -6
  41. package/engine/sessions/orchestrator.cjs +41 -3
  42. package/engine/sessions/prompt.cjs +4 -7
  43. package/engine/sessions/session.cjs +32 -0
  44. package/engine/storm/swarm.cjs +12 -0
  45. package/engine/ui/palette.cjs +14 -2
  46. package/engine/ui/renderer.cjs +37 -0
  47. package/engine/ui/repl.cjs +7 -1
  48. package/engine/workforce/concurrency.cjs +41 -0
  49. package/package.json +1 -1
@@ -17,7 +17,15 @@ const SCHEMA_VERSION = 1;
17
17
  const ALLOCATION_SCHEMA = "agentlas.workload-allocation.v1";
18
18
  const TIERS = Object.freeze(["economy", "balanced", "frontier"]);
19
19
  const TIER_RANK = Object.freeze({ economy: 0, balanced: 1, frontier: 2 });
20
+ // 알려진 값은 랭크 폴백으로만 쓴다 — "유효한가" 게이트로 쓰지 않는다. 2026-07-28 라이브
21
+ // 실측: codex의 모델 카탈로그가 프론티어 모델 하나에서 "ultra"(자동 위임) 리즌
22
+ // 레벨을 광고했는데, normalizeEffort가 이 튜플로 게이트를 걸면 parent-AI의 할당
23
+ // 결정 전체가 통째로 null(무효) 처리된다. 새 값이 나올 때마다 이 튜플을 고치지
24
+ // 않고, resolveEffort가 모델 자체 목록의 순서(=능력 랭크, provider 계약)를
25
+ // 신뢰하도록 뒤집었다. (구체적 모델 id는 이 파일에 절대 하드코딩하지 않는다 —
26
+ // 아래 doesNotMatch 계약 테스트 참고.)
20
27
  const EFFORTS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
28
+ const EFFORT_TOKEN_RE = /^[a-z][a-z0-9-]{0,23}$/;
21
29
  const CAPABILITIES = new Set(["code", "image", "tools", "long-context"]);
22
30
  const PHASES = new Set(["plan", "delegate", "synthesize"]);
23
31
 
@@ -31,8 +39,9 @@ function normalizeTier(value) {
31
39
  }
32
40
 
33
41
  function normalizeEffort(value) {
42
+ // 신택스만 검증한다 — 화이트리스트 존재는 게이트로 쓰지 않는다(위 EFFORTS 주석).
34
43
  const v = String(value || "").toLowerCase().trim();
35
- return EFFORTS.includes(v) ? v : null;
44
+ return EFFORT_TOKEN_RE.test(v) ? v : null;
36
45
  }
37
46
 
38
47
  function normalizeCapabilities(value) {
@@ -251,10 +260,29 @@ function resolveEffort(provider, requested, supported = []) {
251
260
  const available = Array.isArray(supported) ? supported : [];
252
261
  if (!available.length) return null;
253
262
  if (available.includes(requested)) return requested;
254
- const requestedRank = EFFORTS.indexOf(requested);
263
+ // 모델이 정확히 그 값을 광고하지 않을 때만 여기로 온다. 랭크는 모델 자체
264
+ // 목록의 순서(=능력 랭크, provider 계약)를 최우선으로 쓰고, 알려진 7단계
265
+ // 표는 순서 정보가 없는 값의 폴백일 뿐이다 — 화이트리스트 게이트가 아니다.
266
+ // 미광고 known 값은 available 안에서 known-rank가 자기 이하인 마지막 항목
267
+ // 바로 뒤(소수 위치)에 끼워 넣는다 — 무조건 "목록 끝"으로 밀면 사실 available의
268
+ // 상위 항목들보다 낮은 값(예: low/xhigh/max만 있을 때의 "medium")이 전부 통과돼
269
+ // 요청보다 위로 에스컬레이션된다. 표에도 없는 완전 미지의 값만 +Infinity.
270
+ const rank = (value) => {
271
+ const own = available.indexOf(value);
272
+ if (own !== -1) return own;
273
+ const known = EFFORTS.indexOf(value);
274
+ if (known === -1) return Infinity;
275
+ let insertAfter = -1;
276
+ available.forEach((item, index) => {
277
+ const itemKnown = EFFORTS.indexOf(item);
278
+ if (itemKnown !== -1 && itemKnown <= known) insertAfter = index;
279
+ });
280
+ return insertAfter + 0.5;
281
+ };
282
+ const requestedRank = rank(requested);
255
283
  const lower = available
256
- .filter((item) => EFFORTS.indexOf(item) <= requestedRank)
257
- .sort((a, b) => EFFORTS.indexOf(b) - EFFORTS.indexOf(a))[0];
284
+ .filter((item) => rank(item) <= requestedRank)
285
+ .sort((a, b) => rank(b) - rank(a))[0];
258
286
  return lower || available[0] || null;
259
287
  }
260
288
  return null;
@@ -102,6 +102,14 @@ function nearestCommands(token, names) {
102
102
 
103
103
  function main() {
104
104
  const argv = process.argv.slice(2);
105
+ const helpRequested = argv.some((arg) => arg === "--help" || arg === "-h");
106
+ const helpCommand = argv.find((arg) => arg !== "--help" && arg !== "-h" && !arg.startsWith("-"));
107
+ if (helpRequested && helpCommand) {
108
+ const ctx = buildCtx();
109
+ const command = commands.resolveCommandName(helpCommand);
110
+ const code = require("./commands/help.cjs").runForCommand(ctx, command);
111
+ process.exit(typeof code === "number" ? code : 0);
112
+ }
105
113
  // 옵션 정규화: -h/--help/-V/--version 은 하위 명령으로 변환
106
114
  const normalized = argv.map((a) => {
107
115
  if (a === "--help" || a === "-h") return "help";
@@ -111,6 +111,41 @@ function resolveTargetAgent(db, row) {
111
111
  return bySlug;
112
112
  }
113
113
 
114
+ /**
115
+ * 이 터미널이 실행할 수 없는 계열 판정 — 실행 게이트와 due 셀렉션의 유일한 정본.
116
+ * store.runnerUnsupportedSql 의 SQL 술어와 반드시 같은 조건을 본다(갈리면 굶주림이
117
+ * 되돌아온다: 셀렉션이 담은 행을 실행기가 거부하면 그 행이 due 창을 영구 점유한다).
118
+ *
119
+ * Hub 타깃: 데스크탑은 정확 릴리스 핀 + Hub 런타임으로 실행한다
120
+ * (automation-scheduler.ts:573-630 hub_version_pin 게이트) — 터미널에는 그 실행
121
+ * 계층이 없으므로 위장 실행 금지, 정직 스킵.
122
+ * tool_mode 'browser'/'computer-use': 데스크탑은 Agentlas Browser/컴퓨터유즈 러너를
123
+ * 배선하고 권한 프리플라이트까지 건다(automation-scheduler.ts:619-625). 터미널
124
+ * 세션 계층에는 그 러너가 없다 — 평문 세션으로 돌리는 조용한 다운그레이드 대신
125
+ * 정직 스킵으로 Desktop 실행분(회차)을 그대로 남겨 둔다.
126
+ * @returns {{reason:string, message:string}|null}
127
+ */
128
+ function runnerSkip(db, row, ko) {
129
+ if (row.target_type === "hub") {
130
+ return {
131
+ reason: "hub-target-unsupported",
132
+ message: ko
133
+ ? `Hub 타깃 자동화는 터미널 데몬이 실행하지 않습니다(정확 릴리스 핀 실행은 Desktop 스케줄러 몫): ${row.name}`
134
+ : `Hub-target automations are not run by the terminal daemon (exact-release Hub execution belongs to the Desktop scheduler): ${row.name}`,
135
+ };
136
+ }
137
+ const rowToolMode = columnExists(db, "automations", "tool_mode") ? row.tool_mode : null;
138
+ if (rowToolMode === "browser" || rowToolMode === "computer-use") {
139
+ return {
140
+ reason: "tool-mode-unsupported",
141
+ message: ko
142
+ ? `tool_mode '${rowToolMode}' 자동화는 터미널 데몬이 실행하지 않습니다(브라우저/컴퓨터유즈 러너는 Desktop 몫): ${row.name}`
143
+ : `tool_mode '${rowToolMode}' automations are not run by the terminal daemon (the browser/computer-use runner belongs to Desktop): ${row.name}`,
144
+ };
145
+ }
146
+ return null;
147
+ }
148
+
114
149
  /**
115
150
  * 자동화 1건 실행(헤드리스). 권한은 자동화 행의 permission 열(있으면), 없으면 "write".
116
151
  * opts:
@@ -131,26 +166,10 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
131
166
  return { ok: false, skipped: true, reason: "disabled" };
132
167
  }
133
168
  // ── 터미널이 충실히 실행할 수 없는 계열은 리스를 잡지 않고 스킵한다 ──
134
- // 리스/스케줄을 소비하면 데스크탑 스케줄러가 그 회차를 영영 실행하지 못한다.
135
- // Hub 타깃: 데스크탑은 정확 릴리스 핀 + Hub 런타임으로 실행한다
136
- // (automation-scheduler.ts:573-630 hub_version_pin 게이트) — 터미널에는 그
137
- // 실행 계층이 없으므로 위장 실행 금지, 정직 스킵.
138
- if (row.target_type === "hub") {
139
- ctx.err(ko
140
- ? `Hub 타깃 자동화는 터미널 데몬이 실행하지 않습니다(정확 릴리스 핀 실행은 Desktop 스케줄러 몫): ${row.name}`
141
- : `Hub-target automations are not run by the terminal daemon (exact-release Hub execution belongs to the Desktop scheduler): ${row.name}`);
142
- return { ok: false, skipped: true, reason: "hub-target-unsupported" };
143
- }
144
- // tool_mode 'browser'/'computer-use': 데스크탑은 Agentlas Browser/컴퓨터유즈
145
- // 러너를 배선하고 권한 프리플라이트까지 건다(automation-scheduler.ts:619-625).
146
- // 터미널 세션 계층에는 그 러너가 없다 — 평문 세션으로 돌리는 조용한 다운그레이드
147
- // (위장 실행) 대신 정직 스킵으로 Desktop 실행분을 남겨 둔다.
148
- const rowToolMode = columnExists(db, "automations", "tool_mode") ? row.tool_mode : null;
149
- if (rowToolMode === "browser" || rowToolMode === "computer-use") {
150
- ctx.err(ko
151
- ? `tool_mode '${rowToolMode}' 자동화는 터미널 데몬이 실행하지 않습니다(브라우저/컴퓨터유즈 러너는 Desktop 몫): ${row.name}`
152
- : `tool_mode '${rowToolMode}' automations are not run by the terminal daemon (the browser/computer-use runner belongs to Desktop): ${row.name}`);
153
- return { ok: false, skipped: true, reason: "tool-mode-unsupported" };
169
+ const unsupported = runnerSkip(db, row, ko);
170
+ if (unsupported) {
171
+ ctx.err(unsupported.message);
172
+ return { ok: false, skipped: true, reason: unsupported.reason };
154
173
  }
155
174
  if (!store.leaseSupported(db)) {
156
175
  // 리스 열이 없는 DB에서는 Desktop 과의 배타성을 증명할 수 없다 — fail-closed.
@@ -268,17 +287,31 @@ async function daemonTick(ctx, db, opts = {}) {
268
287
  const nowIso = (opts.now || new Date()).toISOString();
269
288
  let due = [];
270
289
  try {
271
- due = store.dueAutomations(db, nowIso, 5);
290
+ // 실행 창은 "이 실행기가 실제로 실행할 수 있는" 행만 담는다 (runnable: true).
291
+ // 미지원 계열/남의 리스 행은 스킵돼도 next_run_at 이 전진하지 않으므로, 창에
292
+ // 담으면 시간순 LIMIT 5 의 머리를 영구 점유해 뒤의 자동화를 전부 굶긴다.
293
+ due = store.dueAutomations(db, nowIso, 5, { runnable: true });
272
294
  } catch (e) {
273
295
  ctx.err("Failed to query due automations: " + String((e && e.message) || e));
274
296
  return 0;
275
297
  }
298
+ // 미지원 계열은 실행 창에서 빠졌어도 존재 사실은 정직하게 알린다 — Desktop 몫으로
299
+ // 남겨둔 회차이므로 틱마다 반복하지 않고 데몬 수명당 1회만 고지한다.
300
+ if (opts.skipAnnounced) {
301
+ let deferred = [];
302
+ // 고지 상한 50 — 실행 창(5)과 달리 이 목록은 실행에 쓰이지 않으므로 넉넉해도 안전하다.
303
+ try { deferred = store.dueAutomations(db, nowIso, 50, { runnable: false }); } catch { /* best-effort */ }
304
+ for (const row of deferred) {
305
+ if (opts.skipAnnounced.has(row.id)) continue;
306
+ const skip = runnerSkip(db, row, ctx.lang === "ko");
307
+ if (!skip) continue;
308
+ ctx.err(skip.message);
309
+ opts.skipAnnounced.add(row.id);
310
+ }
311
+ }
276
312
  let ran = 0;
277
313
  for (const row of due) {
278
314
  if (opts.shouldStop && opts.shouldStop()) break;
279
- // 터미널 미지원 계열(hub 타깃/browser/computer-use)은 due 로 계속 남는다 —
280
- // Desktop 몫으로 남겨둔 것이므로 틱마다 같은 안내를 반복하지 않는다.
281
- if (opts.skipAnnounced && opts.skipAnnounced.has(row.id)) continue;
282
315
  ran += 1;
283
316
  const result = await runAutomationOnce(ctx, db, row, {
284
317
  advanceSchedule: true,
@@ -288,12 +321,15 @@ async function daemonTick(ctx, db, opts = {}) {
288
321
  spawnImpl: opts.spawnImpl,
289
322
  timeoutConfig: opts.timeoutConfig,
290
323
  });
291
- if (
292
- opts.skipAnnounced && result && result.skipped &&
293
- (result.reason === "hub-target-unsupported" || result.reason === "tool-mode-unsupported")
294
- ) {
295
- opts.skipAnnounced.add(row.id);
296
- continue; // 스케줄/1회성 비활성화도 건드리지 않는다 — Desktop 이 실행해야 할 회차다.
324
+ // 스킵은 회차를 소비하지 않는다 — 1회성 비활성화조차 건드리지 않는다.
325
+ // (미지원 계열은 Desktop 몫이고, lease 스킵은 지금 남이 돌리는 회차다. 셀렉션과
326
+ // 실행 사이의 리스 레이스로 스킵된 1회성 행을 여기서 꺼 버리면 그 실행은 영영 사라진다.)
327
+ if (result && result.skipped) {
328
+ if (
329
+ opts.skipAnnounced &&
330
+ (result.reason === "hub-target-unsupported" || result.reason === "tool-mode-unsupported")
331
+ ) opts.skipAnnounced.add(row.id);
332
+ continue;
297
333
  }
298
334
  // 스케줄이 없는(1회성) 행이 남으면 재발화 방지 (v1과 동일).
299
335
  if (!row.schedule || !schedule.nextAutomationRun(row)) {
@@ -340,5 +376,6 @@ module.exports = {
340
376
  resolveTargetAgent,
341
377
  automationContractState,
342
378
  automationSessionChatId,
379
+ runnerSkip,
343
380
  decodeRuntimeSelection,
344
381
  };
@@ -102,6 +102,21 @@ function nextCronRun(cron, from = new Date(), timezone = null) {
102
102
  return null;
103
103
  }
104
104
 
105
+ /**
106
+ * IANA 타임존으로 해석 가능한지만 본다(빈 값 = 로컬존이므로 유효).
107
+ * nextCronRun 은 cron 파싱 실패와 잘못된 존을 똑같이 null 로 접어버리므로,
108
+ * 호출부가 "어느 필드가 틀렸는지"를 구분하려면 이 검사가 따로 필요하다.
109
+ */
110
+ function isValidTimezone(timezone) {
111
+ if (!timezone) return true;
112
+ try {
113
+ new Intl.DateTimeFormat("en-US", { timeZone: String(timezone) });
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
105
120
  function localTimezone() {
106
121
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; }
107
122
  }
@@ -175,6 +190,7 @@ module.exports = {
175
190
  cronField,
176
191
  zonedDateParts,
177
192
  nextCronRun,
193
+ isValidTimezone,
178
194
  localTimezone,
179
195
  legacyScheduleSpec,
180
196
  nextAutomationRun,
@@ -163,11 +163,28 @@ function listRuns(db, limit = 15) {
163
163
  ).all(Math.max(1, limit));
164
164
  }
165
165
 
166
+ /**
167
+ * 다음 회차가 없어 자동화를 끝낼 때만 쓰는 단방향(끄기 전용) write.
168
+ *
169
+ * advanceAfterRun 은 실행 시작 시점의 row 스냅샷을 들고 있는데, 그 사이 사용자가
170
+ * 다른 터미널이나 Desktop 토글로 `automation off` 를 할 수 있다(에이전트 실행은
171
+ * 수 분~수십 분). 스냅샷의 enabled 를 되쓰면 그 끄기가 조용히 되돌아가고
172
+ * ("Disabled: … morning" 을 보고 exit 0 인데도) 다음 예정 시각에 또 발화한다.
173
+ * 그래서 enabled 는 "켜기"로는 절대 쓰지 않고, 종료 조건일 때만 0 으로 내린다.
174
+ */
175
+ function disableExhausted(db, id) {
176
+ db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(id);
177
+ }
178
+
166
179
  /**
167
180
  * 실행 후 스케줄 북키핑 — v1 runAutomationOnce 의 성공/실패 분기와 동일:
168
181
  * - 성공: run_count 증가. 실패: last_run_at 만 갱신(성공 카운트 오염 금지).
169
182
  * - advanceSchedule(데몬 경로)일 때만 next_run_at 전진; 다음 시각이 없으면 비활성화.
170
183
  * - run-now(advanceSchedule=false)는 스케줄을 건드리지 않는다(앱 advanceSchedule=false와 동일).
184
+ *
185
+ * enabled 는 여기서 스냅샷 값으로 되쓰지 않는다 — disableExhausted 주석 참조.
186
+ * (같은 이유로 성공/실패 두 분기 모두 같은 규칙을 따라야 한다: 한쪽만 고치면
187
+ * 실패로 끝난 실행이 여전히 끈 자동화를 부활시킨다.)
171
188
  */
172
189
  function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } = {}) {
173
190
  const hasRunCount = columnExists(db, "automations", "run_count");
@@ -177,12 +194,13 @@ function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } =
177
194
  if (shouldAdvance) {
178
195
  if (hasRunCount) {
179
196
  db.prepare(
180
- "UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at = ?, enabled = ? WHERE id = ?",
181
- ).run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
197
+ "UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at = ? WHERE id = ?",
198
+ ).run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
182
199
  } else {
183
- db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?")
184
- .run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
200
+ db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ? WHERE id = ?")
201
+ .run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
185
202
  }
203
+ if (!advance) disableExhausted(db, row.id);
186
204
  } else if (hasRunCount) {
187
205
  db.prepare("UPDATE automations SET last_run_at = ?, run_count = run_count + 1 WHERE id = ?")
188
206
  .run(ranAt.toISOString(), row.id);
@@ -191,25 +209,64 @@ function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } =
191
209
  }
192
210
  // max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
193
211
  if (row.max_runs && (row.run_count || 0) + 1 >= row.max_runs) {
194
- db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(row.id);
212
+ disableExhausted(db, row.id);
195
213
  return { advance, maxRunsReached: true };
196
214
  }
197
215
  } else if (shouldAdvance) {
198
- db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?")
199
- .run(ranAt.toISOString(), advance ? advance.toISOString() : null, advance ? row.enabled : 0, row.id);
216
+ db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ? WHERE id = ?")
217
+ .run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
218
+ if (!advance) disableExhausted(db, row.id);
200
219
  } else {
201
220
  db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(ranAt.toISOString(), row.id);
202
221
  }
203
222
  return { advance, maxRunsReached: false };
204
223
  }
205
224
 
206
- /** 데몬 폴링: 활성 + 스케줄 트리거 + next_run_at 도래분. trigger_type 열은 방어적. */
207
- function dueAutomations(db, nowIso = new Date().toISOString(), limit = 5) {
225
+ /**
226
+ * 실행기가 실행할 없는 계열의 SQL 술어 — daemon.runnerSkip 게이트와 짝이다.
227
+ * (hub 타깃 / tool_mode browser·computer-use = Desktop 몫.)
228
+ * IFNULL 필수: SQLite 3치 논리에서 NULL 열이 있으면 NOT (…) 이 NULL 이 되어
229
+ * 멀쩡한 행까지 조용히 사라진다.
230
+ */
231
+ function runnerUnsupportedSql(db) {
232
+ const parts = ["IFNULL(target_type,'') = 'hub'"];
233
+ if (columnExists(db, "automations", "tool_mode")) {
234
+ parts.push("IFNULL(tool_mode,'') IN ('browser','computer-use')");
235
+ }
236
+ return `(${parts.join(" OR ")})`;
237
+ }
238
+
239
+ /**
240
+ * 데몬 폴링: 활성 + 스케줄 트리거 + next_run_at 도래분. trigger_type 열은 방어적.
241
+ *
242
+ * opts.runnable=true (데몬 실행 창): 이 실행기가 실행할 수 없는 행을 셀렉션에서 뺀다.
243
+ * 이유(굶주림 근본): 미지원 행(Desktop 몫)과 남이 리스를 쥔 행은 스킵돼도 next_run_at
244
+ * 이 전진하지 않는다 — 시간순 LIMIT n 창의 머리에 영구히 남아 뒤의 실행 가능한
245
+ * 자동화를 전부 굶긴다(데몬은 멀쩡해 보이고 아무것도 안 돈다). 실행 못 할 행은
246
+ * 애초에 창에 담지 않는 것이 유일한 근본 수리다 — 그 행의 회차는 그대로 보존된다.
247
+ * opts.runnable=false: 그 미지원 행만(고지용). 미지정: 예전 그대로 전체.
248
+ */
249
+ function dueAutomations(db, nowIso = new Date().toISOString(), limit = 5, opts = {}) {
208
250
  if (!tableExists(db, "automations")) return [];
209
- const triggerFilter = columnExists(db, "automations", "trigger_type") ? "AND trigger_type = 'schedule'" : "";
251
+ const where = ["enabled = 1"];
252
+ const params = [];
253
+ if (columnExists(db, "automations", "trigger_type")) where.push("trigger_type = 'schedule'");
254
+ where.push("next_run_at IS NOT NULL", "next_run_at <= ?");
255
+ params.push(nowIso);
256
+ if (opts.runnable === true) {
257
+ where.push(`NOT ${runnerUnsupportedSql(db)}`);
258
+ if (leaseSupported(db)) {
259
+ // 남이 유효 리스를 쥔 행은 claimAutomation 이 반드시 실패한다 — 같은 굶주림 경로.
260
+ where.push("(claimed_at IS NULL OR claimed_at < ?)");
261
+ params.push(new Date(Date.parse(nowIso) - LEASE_TTL_MS).toISOString());
262
+ }
263
+ } else if (opts.runnable === false) {
264
+ where.push(runnerUnsupportedSql(db));
265
+ }
266
+ params.push(limit);
210
267
  return db.prepare(
211
- `SELECT * FROM automations WHERE enabled = 1 ${triggerFilter} AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT ?`,
212
- ).all(nowIso, limit);
268
+ `SELECT * FROM automations WHERE ${where.join(" AND ")} ORDER BY next_run_at ASC LIMIT ?`,
269
+ ).all(...params);
213
270
  }
214
271
 
215
272
  module.exports = {
@@ -228,4 +285,5 @@ module.exports = {
228
285
  listRuns,
229
286
  advanceAfterRun,
230
287
  dueAutomations,
288
+ runnerUnsupportedSql,
231
289
  };