spinuptui 1.3.0-alpha.3 → 1.3.0-alpha.5
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/CHANGELOG.md +32 -1
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/config.ts +32 -0
- package/src/index.tsx +11 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +14 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +190 -0
- package/src/lib/beaconListen.ts +355 -0
- package/src/lib/beaconListenInstall.ts +251 -0
- package/src/lib/cliBeacon.ts +115 -3
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliScheduler.ts +30 -1
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +189 -1
- package/src/lib/schedulerExtend.ts +105 -0
- package/src/lib/schedulerInstall.ts +36 -15
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +9 -1
- package/src/modules/contract.ts +34 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +147 -6
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +8 -0
- package/src/ui/store.tsx +11 -0
- package/src/ui/views/BeaconSetup.tsx +11 -7
- package/src/ui/views/ListenerSetup.tsx +205 -0
- package/src/ui/views/Modules.tsx +70 -5
- package/src/ui/views/SentinelSetup.tsx +171 -0
|
@@ -7,7 +7,8 @@ import { agentOf, parseJobText, splitArgs, validateJob } from "./schedulerJobs.t
|
|
|
7
7
|
import { quoteArg } from "./cliScheduler.ts"
|
|
8
8
|
import { renderPlist, renderSystemd } from "./schedulerInstall.ts"
|
|
9
9
|
import { runModuleJobs } from "./schedulerTick.ts"
|
|
10
|
-
import { summaryOf } from "./schedulerRun.ts"
|
|
10
|
+
import { agentCommand, BLOCKED_EXIT, outcomeOf, summaryOf } from "./schedulerRun.ts"
|
|
11
|
+
import { stillRunningHeartbeat } from "./heartbeat.ts"
|
|
11
12
|
import { tryLock } from "./flock.ts"
|
|
12
13
|
|
|
13
14
|
const at = (s: string) => parseWhen(s)
|
|
@@ -351,3 +352,190 @@ describe("station claim", () => {
|
|
|
351
352
|
expect(await decideClaim(b, "nas")).toEqual({ run: true, claimed: true })
|
|
352
353
|
})
|
|
353
354
|
})
|
|
355
|
+
|
|
356
|
+
// ---- extend ----------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
import { mkdirSync } from "node:fs"
|
|
359
|
+
import { addPeriod, extendJob, newExpiry, setHeaderValue } from "./schedulerExtend.ts"
|
|
360
|
+
import { fmtDate, parseDate } from "./schedule.ts"
|
|
361
|
+
|
|
362
|
+
describe("extend", () => {
|
|
363
|
+
const day = (s: string) => parseDate(s)
|
|
364
|
+
|
|
365
|
+
test("periods, clamped to the month's last day", () => {
|
|
366
|
+
expect(fmtDate(addPeriod(day("2026-10-29"), "1y"))).toBe("2027-10-29")
|
|
367
|
+
expect(fmtDate(addPeriod(day("2028-02-29"), "1y"))).toBe("2029-02-28")
|
|
368
|
+
expect(fmtDate(addPeriod(day("2026-01-31"), "1m"))).toBe("2026-02-28")
|
|
369
|
+
expect(fmtDate(addPeriod(day("2026-12-30"), "2w"))).toBe("2027-01-13")
|
|
370
|
+
expect(fmtDate(addPeriod(day("2026-10-29"), "30d"))).toBe("2026-11-28")
|
|
371
|
+
expect(() => addPeriod(day("2026-10-29"), "0d")).toThrow(/--by/)
|
|
372
|
+
expect(() => addPeriod(day("2026-10-29"), "1q")).toThrow(/--by/)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("--by counts from the expiry, or from today once it has passed", () => {
|
|
376
|
+
expect(newExpiry("2026-11-06", { by: "1y" }, at("2026-10-29 09:00"))).toBe("2027-11-06")
|
|
377
|
+
expect(newExpiry("2026-10-01", { by: "1y" }, at("2026-10-29 09:00"))).toBe("2027-10-29")
|
|
378
|
+
expect(newExpiry("2026-11-06", { date: "2027-01-05" }, at("2026-10-29 09:00"))).toBe("2027-01-05")
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
test("only the expires: line changes, comment kept", () => {
|
|
382
|
+
const text = "---\nid: x\nexpires: 2026-10-28 # stop after the audit\nkind: script\n---\necho hi\n"
|
|
383
|
+
expect(setHeaderValue(text, "expires", "2027-10-28")).toBe("---\nid: x\nexpires: 2027-10-28 # stop after the audit\nkind: script\n---\necho hi\n")
|
|
384
|
+
expect(() => setHeaderValue("---\nid: x\n---\nbody", "expires", "2027-01-01")).toThrow(/no expires/)
|
|
385
|
+
// A body line that looks like a header isn't touched.
|
|
386
|
+
expect(setHeaderValue("---\nexpires: 2026-01-01\n---\nexpires: keep\n", "expires", "2027-01-01")).toBe("---\nexpires: 2027-01-01\n---\nexpires: keep\n")
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const job = "---\nid: nightly\nkind: script\nevery: daily 22:00\nexpires: 2026-11-06\ncreated: 2026-10-20 09:00\ncreated_by: test\n---\necho hi\n"
|
|
390
|
+
|
|
391
|
+
test("a local source: rewritten in place, and an earlier date is refused", async () => {
|
|
392
|
+
const dir = mkdtempSync(join(tmpdir(), "extend-"))
|
|
393
|
+
mkdirSync(join(dir, "jobs"))
|
|
394
|
+
writeFile(join(dir, "jobs", "nightly.md"), job)
|
|
395
|
+
const s: Source = { name: "local", path: dir, gitRoot: null, git: false, queueOnly: false }
|
|
396
|
+
const r = await extendJob(s, "nightly", { by: "1y" }, { now: at("2026-10-29 09:00") })
|
|
397
|
+
expect(r).toMatchObject({ ok: true, from: "2026-11-06", to: "2027-11-06", pushed: null })
|
|
398
|
+
expect(readFile(join(dir, "jobs", "nightly.md"), "utf8")).toBe(job.replace("2026-11-06", "2027-11-06"))
|
|
399
|
+
expect(await extendJob(s, "nightly", { date: "2027-01-01" })).toMatchObject({ ok: false, reason: "not_later" })
|
|
400
|
+
expect(await extendJob(s, "nope", { by: "1y" })).toMatchObject({ ok: false, reason: "unknown_job" })
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test("a git source: one commit, pushed, seen by the other clone", async () => {
|
|
404
|
+
const { a, b } = await twoStations()
|
|
405
|
+
mkdirSync(join(a.path, "jobs"))
|
|
406
|
+
writeFile(join(a.path, "jobs", "nightly.md"), job)
|
|
407
|
+
await sh(a.path, "git", "add", "jobs")
|
|
408
|
+
await sh(a.path, "git", "commit", "-q", "-m", "add")
|
|
409
|
+
await sh(a.path, "git", "push", "-q")
|
|
410
|
+
// Queued from the other clone, which hasn't pulled the job yet.
|
|
411
|
+
const r = await extendJob(b, "nightly", { by: "1y" }, { now: at("2026-10-29 09:00") })
|
|
412
|
+
expect(r).toMatchObject({ ok: true, to: "2027-11-06", pushed: true })
|
|
413
|
+
expect(await sh(b.path, "git", "log", "-1", "--format=%s")).toBe("job: extend nightly to 2027-11-06")
|
|
414
|
+
await sh(a.path, "git", "pull", "-q")
|
|
415
|
+
expect(readFile(join(a.path, "jobs", "nightly.md"), "utf8")).toContain("expires: 2027-11-06")
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ---- /jobs on the pager ------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
import { extendReply, jobsReply } from "../modules/scheduler.ts"
|
|
422
|
+
|
|
423
|
+
describe("/jobs", () => {
|
|
424
|
+
const write = (dir: string, id: string, schedule: string, expires: string) =>
|
|
425
|
+
writeFile(join(dir, `${id}.md`), `---\nid: ${id}\nkind: script\n${schedule}\nexpires: ${expires}\ncreated: 2026-10-01 09:00\ncreated_by: test\n---\necho hi\n`)
|
|
426
|
+
|
|
427
|
+
test("lists jobs by expiry, flags the soon ones, and offers Extend on recurring ones", async () => {
|
|
428
|
+
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "jobs-cfg-"))
|
|
429
|
+
process.env.SPINUPTUI_HOME = mkdtempSync(join(tmpdir(), "jobs-home-"))
|
|
430
|
+
const jobs = join(process.env.SPINUPTUI_HOME, "scheduler", "jobs")
|
|
431
|
+
mkdirSync(jobs, { recursive: true })
|
|
432
|
+
write(jobs, "nightly", "every: daily 22:00", "2026-11-06")
|
|
433
|
+
write(jobs, "audit", "when: 2026-10-30 09:00", "2026-11-02")
|
|
434
|
+
write(jobs, "longterm", "every: weekdays 08:00", "2028-10-29")
|
|
435
|
+
write(jobs, "ancient", "every: daily 08:00", "2026-01-01")
|
|
436
|
+
const now = at("2026-10-29 12:00")
|
|
437
|
+
const r = await jobsReply({ now, pull: false })
|
|
438
|
+
expect(r.html).toBe(true)
|
|
439
|
+
expect(r.text).toContain("</b> (3)")
|
|
440
|
+
const order = ["audit", "nightly", "longterm"].map((id) => r.text.indexOf(`<b>${id}</b>`))
|
|
441
|
+
expect([...order].sort((a, b) => a - b)).toEqual(order)
|
|
442
|
+
expect(r.text).toContain("⚠️ <b>nightly</b>\nExpires Nov 6 (in 8 days)\nscript · daily 22:00 · next tonight 22:00")
|
|
443
|
+
expect(r.text).toContain("⚠️ <b>audit</b>\nExpires Nov 2 (in 4 days)\nscript · once, tomorrow 09:00")
|
|
444
|
+
expect(r.text).toContain("<b>longterm</b>\nExpires Oct 29, 2028\nscript · weekdays 08:00 · next tomorrow 08:00")
|
|
445
|
+
expect(r.text).toContain("1 finished or long-expired not shown.")
|
|
446
|
+
// A one-shot isn't offered a year of nothing.
|
|
447
|
+
expect(r.buttons).toEqual([[{ text: "Extend nightly 1 year", action: "scheduler.extend", payload: { source: "local", id: "nightly", from: "2026-11-06" } }]])
|
|
448
|
+
|
|
449
|
+
const done = await extendReply(r.buttons![0]![0]!.payload, now)
|
|
450
|
+
expect(done.text).toBe("✅ Extended nightly: it now expires 2027-11-06 (was 2026-11-06).")
|
|
451
|
+
// The same button again (say, from an older /jobs reply) doesn't stack a second year.
|
|
452
|
+
expect((await extendReply(r.buttons![0]![0]!.payload, now)).text).toMatch(/^Couldn't extend nightly: nightly already expires 2027-11-06/)
|
|
453
|
+
delete process.env.SPINUPTUI_HOME
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
describe("blocked", () => {
|
|
458
|
+
test("an agent that exits 0 saying it's blocked did no work", () => {
|
|
459
|
+
expect(outcomeOf(true, 0, false, "SUMMARY: login lapsed.\nSTATUS: blocked\nNeeds a re-login.")).toBe("blocked")
|
|
460
|
+
expect(outcomeOf(true, 0, false, "SUMMARY: all fine.\n\nThe status: blocked queue is empty.")).toBe("ok")
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test("a script says so with its exit code", () => {
|
|
464
|
+
expect(outcomeOf(false, BLOCKED_EXIT, false, "")).toBe("blocked")
|
|
465
|
+
expect(outcomeOf(false, 1, false, "")).toBe("failed")
|
|
466
|
+
expect(outcomeOf(false, 0, false, "STATUS: blocked")).toBe("ok")
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
test("a lapsed login and a timeout keep their own statuses", () => {
|
|
470
|
+
expect(outcomeOf(true, 1, false, "Please run /login")).toBe("auth")
|
|
471
|
+
expect(outcomeOf(true, null, true, "working")).toBe("timeout")
|
|
472
|
+
expect(outcomeOf(true, 1, false, "boom")).toBe("failed")
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test("a blocked one-shot isn't done", () => {
|
|
476
|
+
const job = { when: "2026-10-20 08:00", expires: "2026-10-30" }
|
|
477
|
+
const now = at("2026-10-21 08:00")
|
|
478
|
+
expect(nextRunText(job, { last_scheduled: "2026-10-20 08:00", last_status: "blocked" }, now)).toBe("blocked")
|
|
479
|
+
expect(nextRunText(job, { last_scheduled: "2026-10-20 08:00", last_status: "ok" }, now)).toBe("done")
|
|
480
|
+
// It waits for the operator: a blocked run doesn't retry by itself.
|
|
481
|
+
expect(dueOccurrence(job, { last_scheduled: "2026-10-20 08:00", last_status: "blocked" }, now)).toBeNull()
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test("a module job pages once when blocked, and again when it recovers", async () => {
|
|
485
|
+
const dir = mkdtempSync(join(tmpdir(), "blocked-"))
|
|
486
|
+
const pages: string[] = []
|
|
487
|
+
const step = (result: { ok: boolean; output: string; blocked?: boolean }, minute: string) =>
|
|
488
|
+
runModuleJobs([{ id: "t", every: "5m", command: ["x"] }], { stateDir: dir, now: at(`2026-10-20 10:${minute}`), run: async () => result, page: async (t) => (pages.push(t), "sent") })
|
|
489
|
+
await step({ ok: false, output: "login", blocked: true }, "00")
|
|
490
|
+
await step({ ok: false, output: "login", blocked: true }, "05")
|
|
491
|
+
await step({ ok: true, output: "" }, "10")
|
|
492
|
+
expect(pages.length).toBe(2)
|
|
493
|
+
expect(pages[0]).toContain("blocked")
|
|
494
|
+
expect(pages[1]).toContain("working again")
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
describe("the agent adapter", () => {
|
|
499
|
+
test("carries the always-deny list and options, for any caller", () => {
|
|
500
|
+
const cmd = agentCommand({ agent: "claude", prompt: "p", workdir: "/w", allow: "Read", addDirs: "/a,/b", model: "m" }, { PATH: `/bin:${process.env.PATH}` })
|
|
501
|
+
if (typeof cmd === "string") return // no claude here; the string is the reason
|
|
502
|
+
expect(cmd).toContain("--disallowedTools")
|
|
503
|
+
expect(cmd.filter((a) => a === "--add-dir").length).toBe(2)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
test("says why when the binary is missing", () => {
|
|
507
|
+
expect(agentCommand({ agent: "codex", prompt: "p", workdir: "/w" }, { PATH: "/nonexistent" })).toBe("codex not found on PATH")
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
describe("a dispatcher tick that finds the lock held", () => {
|
|
512
|
+
test("tells Kuma the station is alive, unless it can't page", () => {
|
|
513
|
+
expect(stillRunningHeartbeat(null)).toEqual({ status: "up", msg: "tick still running" })
|
|
514
|
+
expect(stillRunningHeartbeat("Beacon's last page failed").status).toBe("down")
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
test("pings and records itself; a manual one doesn't", async () => {
|
|
518
|
+
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "stilltick-"))
|
|
519
|
+
const { schedulerStateDir, readLastTick } = await import("./schedulerJobs.ts")
|
|
520
|
+
const { saveConfig } = await import("../config.ts")
|
|
521
|
+
const { tick } = await import("./schedulerTick.ts")
|
|
522
|
+
const pings: string[] = []
|
|
523
|
+
const server = Bun.serve({ port: 0, fetch: (r) => (pings.push(r.url), Response.json({ ok: true })) })
|
|
524
|
+
try {
|
|
525
|
+
await saveConfig({
|
|
526
|
+
scheduler: { heartbeat: { url: `http://localhost:${server.port}/api/push/x`, setAt: "now" } },
|
|
527
|
+
beacon: { channels: { pager: { type: "telegram", botToken: "1:a", chatId: "2" } } },
|
|
528
|
+
})
|
|
529
|
+
const lock = tryLock(join(schedulerStateDir(), "tick.lock"))!
|
|
530
|
+
expect(await tick({ via: "manual" })).toMatchObject({ skipped: "locked" })
|
|
531
|
+
expect(pings.length).toBe(0)
|
|
532
|
+
expect(await tick({ via: "dispatcher" })).toMatchObject({ skipped: "locked" })
|
|
533
|
+
lock.release()
|
|
534
|
+
expect(pings.length).toBe(1)
|
|
535
|
+
expect(pings[0]).toContain("still")
|
|
536
|
+
expect(readLastTick()).toMatchObject({ stillRunning: true, via: "dispatcher", heartbeat: { ok: true } })
|
|
537
|
+
} finally {
|
|
538
|
+
server.stop(true)
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Moving a job's `expires:` later — `spinuptui scheduler extend`, and the
|
|
2
|
+
// "Extend 1 year" button on Beacon's /jobs reply. Both go through extendJob(),
|
|
3
|
+
// so a tap and a command commit the same change the same way.
|
|
4
|
+
//
|
|
5
|
+
// The job file is edited in place: only the `expires:` line changes, keeping the
|
|
6
|
+
// header's order and any trailing " # comment", so the commit is a one-line diff.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import { fmtDate, parseDate } from "./schedule.ts"
|
|
11
|
+
import { jobsDir, parseJobText, validateJob, type Source } from "./schedulerJobs.ts"
|
|
12
|
+
import { git, gitPull } from "./schedulerGit.ts"
|
|
13
|
+
|
|
14
|
+
export const PERIOD_RE = /^([1-9]\d{0,2})([dwmy])$/
|
|
15
|
+
|
|
16
|
+
// `date` moved forward by a period like 30d, 2w, 6m or 1y. Months and years
|
|
17
|
+
// clamp to the month's last day, so 2028-02-29 + 1y is 2029-02-28.
|
|
18
|
+
export function addPeriod(date: Date, period: string): Date {
|
|
19
|
+
const m = period.trim().toLowerCase().match(PERIOD_RE)
|
|
20
|
+
if (!m || +m[1]! === 0) throw new Error(`--by takes a period like 30d, 2w, 6m or 1y, got "${period}"`)
|
|
21
|
+
const n = +m[1]!
|
|
22
|
+
const unit = m[2]!
|
|
23
|
+
if (unit === "d" || unit === "w") return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n * (unit === "w" ? 7 : 1))
|
|
24
|
+
const months = unit === "y" ? n * 12 : n
|
|
25
|
+
const target = new Date(date.getFullYear(), date.getMonth() + months, 1)
|
|
26
|
+
const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate()
|
|
27
|
+
return new Date(target.getFullYear(), target.getMonth(), Math.min(date.getDate(), last))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The new expiry: an explicit date, or a period added to the current expiry —
|
|
31
|
+
// or to today, when the job has already expired, so "1y" always means a year
|
|
32
|
+
// of runs from now on.
|
|
33
|
+
export function newExpiry(current: string, to: { date?: string; by?: string }, today: Date): string {
|
|
34
|
+
if (to.date) return fmtDate(parseDate(to.date))
|
|
35
|
+
const cur = parseDate(current)
|
|
36
|
+
const day = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
|
37
|
+
return fmtDate(addPeriod(cur < day ? day : cur, to.by!))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The job file's text with one header value replaced, everything else as written.
|
|
41
|
+
export function setHeaderValue(text: string, key: string, value: string): string {
|
|
42
|
+
const lines = text.split("\n")
|
|
43
|
+
for (let i = 1; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i]!
|
|
45
|
+
if (line.trim() === "---") break
|
|
46
|
+
const colon = line.indexOf(":")
|
|
47
|
+
if (colon === -1 || line.slice(0, colon).trim().toLowerCase() !== key) continue
|
|
48
|
+
const hash = line.indexOf(" #", colon)
|
|
49
|
+
const comment = hash === -1 ? "" : line.slice(hash).replace(/^\s*/, " ")
|
|
50
|
+
lines[i] = `${line.slice(0, colon + 1)} ${value}${comment}`
|
|
51
|
+
return lines.join("\n")
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`the header has no ${key}: line`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ExtendResult =
|
|
57
|
+
| { ok: true; source: string; id: string; from: string; to: string; path: string; pushed: boolean | null }
|
|
58
|
+
| { ok: false; reason: string; message: string; remedy?: string }
|
|
59
|
+
|
|
60
|
+
export async function extendJob(s: Source, id: string, to: { date?: string; by?: string }, opts: { now?: Date; push?: boolean } = {}): Promise<ExtendResult> {
|
|
61
|
+
const push = opts.push ?? true
|
|
62
|
+
if (s.git && push) {
|
|
63
|
+
const e = await gitPull(s.path)
|
|
64
|
+
if (e) return { ok: false, reason: "git_failed", message: `${s.name}: ${e}` }
|
|
65
|
+
}
|
|
66
|
+
const path = join(jobsDir(s), `${id}.md`)
|
|
67
|
+
if (!existsSync(path)) return { ok: false, reason: "unknown_job", message: `No ${s.name}/jobs/${id}.md.` }
|
|
68
|
+
const text = readFileSync(path, "utf8")
|
|
69
|
+
let from: string
|
|
70
|
+
let next: string
|
|
71
|
+
let updated: string
|
|
72
|
+
try {
|
|
73
|
+
from = parseJobText(text).header.expires ?? ""
|
|
74
|
+
if (!from) throw new Error("the header has no expires: line")
|
|
75
|
+
next = newExpiry(from, to, opts.now ?? new Date())
|
|
76
|
+
updated = setHeaderValue(text, "expires", next)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { ok: false, reason: "invalid_job", message: `${s.name}/jobs/${id}.md: ${(err as Error).message}` }
|
|
79
|
+
}
|
|
80
|
+
if (parseDate(next) <= parseDate(from)) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: "not_later",
|
|
84
|
+
message: `${id} already expires ${from}; ${next} isn't later.`,
|
|
85
|
+
remedy: "Give a later date. To stop a job sooner, cancel it.",
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const parsed = parseJobText(updated)
|
|
89
|
+
const problems = validateJob(parsed.header, parsed.body, `${id}.md`)
|
|
90
|
+
if (problems.length) return { ok: false, reason: "invalid_job", message: `Not extended: ${problems.join("; ")}` }
|
|
91
|
+
|
|
92
|
+
writeFileSync(`${path}.tmp`, updated)
|
|
93
|
+
renameSync(`${path}.tmp`, path)
|
|
94
|
+
let pushed: boolean | null = null
|
|
95
|
+
if (s.git && push) {
|
|
96
|
+
await git(s.path, "add", "--", path)
|
|
97
|
+
await git(s.path, "commit", "--quiet", "-m", `job: extend ${id} to ${next}`, "--", path)
|
|
98
|
+
const r = await git(s.path, "push", "--quiet")
|
|
99
|
+
if (r.code !== 0) {
|
|
100
|
+
return { ok: false, reason: "push_failed", message: `Extended ${s.name}/jobs/${id}.md to ${next} and committed it, but the push failed, so other machines won't see it until it's pushed:\n${r.out}` }
|
|
101
|
+
}
|
|
102
|
+
pushed = true
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, source: s.name, id, from, to: next, path, pushed }
|
|
105
|
+
}
|
|
@@ -110,10 +110,21 @@ export function renderSystemd(p: { label: string; programArguments: string[]; en
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
// The release binary a station unit runs, and the PATH it runs with. Shared by
|
|
114
|
+
// Scheduler's dispatcher and Beacon's listener so both run the same install.
|
|
115
|
+
export interface Runner {
|
|
116
|
+
bin: string
|
|
117
|
+
checkout: string | null
|
|
118
|
+
// The command line up to the subcommand: [bun, bin] or [bin].
|
|
119
|
+
prefix: string[]
|
|
120
|
+
env: Record<string, string>
|
|
121
|
+
missingTools: string[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveRunner(opts: { bin?: string | null } = {}): ({ ok: true } & Runner) | { ok: false; reason: string; message: string; remedy?: string } {
|
|
114
125
|
const platform = process.platform
|
|
115
126
|
if (platform !== "darwin" && platform !== "linux") {
|
|
116
|
-
return { ok: false, reason: "unsupported_platform", message: `
|
|
127
|
+
return { ok: false, reason: "unsupported_platform", message: `SpinupTUI's station units support macOS and Linux, not ${platform}.` }
|
|
117
128
|
}
|
|
118
129
|
const searchPath = [process.env.PATH ?? "", join(homedir(), ".bun/bin")].join(":")
|
|
119
130
|
const bin = opts.bin ?? Bun.which("spinuptui", { PATH: searchPath })
|
|
@@ -121,7 +132,7 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
|
|
|
121
132
|
return {
|
|
122
133
|
ok: false,
|
|
123
134
|
reason: "spinuptui_not_found",
|
|
124
|
-
message: opts.bin ? `--bin ${opts.bin} does not exist.` : "No global `spinuptui` on the PATH for the
|
|
135
|
+
message: opts.bin ? `--bin ${opts.bin} does not exist.` : "No global `spinuptui` on the PATH for the unit to run.",
|
|
125
136
|
remedy: "Install it globally first: bun add -g spinuptui",
|
|
126
137
|
}
|
|
127
138
|
}
|
|
@@ -136,13 +147,13 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
|
|
|
136
147
|
return {
|
|
137
148
|
ok: false,
|
|
138
149
|
reason: "checkout_bin",
|
|
139
|
-
message: `The \`spinuptui\` on the PATH (${bin}) runs a development checkout (${checkout}).
|
|
150
|
+
message: `The \`spinuptui\` on the PATH (${bin}) runs a development checkout (${checkout}). A station unit must run a release, so nothing unattended ever runs whatever is checked out.`,
|
|
140
151
|
remedy: "Install a release globally (bun add -g spinuptui), or pass --bin <path> to override deliberately.",
|
|
141
152
|
}
|
|
142
153
|
}
|
|
143
154
|
const shebang = shebangOf(real)
|
|
144
155
|
const bun = /\bbun(\.exe)?$/.test(process.execPath) ? process.execPath : (Bun.which("bun", { PATH: searchPath }) ?? "bun")
|
|
145
|
-
const
|
|
156
|
+
const prefix = shebang?.includes("bun") ? [bun, bin] : [bin]
|
|
146
157
|
|
|
147
158
|
const dirs: string[] = [dirname(bun), dirname(bin)]
|
|
148
159
|
for (const t of TOOLS) {
|
|
@@ -152,12 +163,19 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
|
|
|
152
163
|
if (p && !SYSTEM_PATH.includes(dirname(p))) dirs.push(dirname(p))
|
|
153
164
|
}
|
|
154
165
|
for (const d of SYSTEM_PATH) if (existsSync(d)) dirs.push(d)
|
|
155
|
-
const env: Record<string, string> = {
|
|
156
|
-
PATH: [...new Set(dirs)].join(":"),
|
|
157
|
-
SPINUPTUI_SCHEDULER_DISPATCHER: "1",
|
|
158
|
-
}
|
|
166
|
+
const env: Record<string, string> = { PATH: [...new Set(dirs)].join(":") }
|
|
159
167
|
if (process.env.XDG_CONFIG_HOME) env.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME
|
|
160
168
|
if (process.env.SPINUPTUI_HOME) env.SPINUPTUI_HOME = process.env.SPINUPTUI_HOME
|
|
169
|
+
return { ok: true, bin, checkout, prefix, env, missingTools: TOOLS.filter((t) => !Bun.which(t, { PATH: env.PATH })) }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
|
|
173
|
+
const runner = resolveRunner(opts)
|
|
174
|
+
if (!runner.ok) return runner
|
|
175
|
+
const platform = process.platform as "darwin" | "linux"
|
|
176
|
+
const { bin, checkout } = runner
|
|
177
|
+
const programArguments = [...runner.prefix, "scheduler", "tick"]
|
|
178
|
+
const env: Record<string, string> = { ...runner.env, SPINUPTUI_SCHEDULER_DISPATCHER: "1" }
|
|
161
179
|
const label = schedulerLabel()
|
|
162
180
|
// One clock per machine: a dispatcher installed under another label would
|
|
163
181
|
// tick the same jobs alongside this one.
|
|
@@ -172,8 +190,7 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
|
|
|
172
190
|
}
|
|
173
191
|
const notes: string[] = [`Jobs and reports live in ${moduleWorkDir("scheduler")} (created on first use).`]
|
|
174
192
|
if (checkout) notes.push(`--bin points into a checkout (${checkout}); fine for testing, never for a station.`)
|
|
175
|
-
|
|
176
|
-
if (missing.length) notes.push(`Not found, so jobs that need them will fail: ${missing.join(", ")}.`)
|
|
193
|
+
if (runner.missingTools.length) notes.push(`Not found, so jobs that need them will fail: ${runner.missingTools.join(", ")}.`)
|
|
177
194
|
|
|
178
195
|
if (platform === "darwin") {
|
|
179
196
|
const unitPath = join(homedir(), "Library/LaunchAgents", `${label}.plist`)
|
|
@@ -216,18 +233,22 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
|
|
|
216
233
|
// treats `scheduler tick` as an unknown subcommand and would launch the TUI
|
|
217
234
|
// under launchd every 5 minutes.
|
|
218
235
|
export async function binHasScheduler(plan: Pick<InstallPlan, "programArguments" | "env">): Promise<boolean> {
|
|
236
|
+
return helpIncludes(plan.programArguments.slice(0, -2), plan.env.PATH ?? "", "spinuptui scheduler")
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function helpIncludes(prefix: string[], path: string, needle: string): Promise<boolean> {
|
|
219
240
|
try {
|
|
220
|
-
const p = spawn([...
|
|
241
|
+
const p = spawn([...prefix, "--help"], {
|
|
221
242
|
stdin: "ignore",
|
|
222
243
|
stdout: "pipe",
|
|
223
244
|
stderr: "ignore",
|
|
224
|
-
env: { ...process.env, PATH:
|
|
245
|
+
env: { ...process.env, PATH: path },
|
|
225
246
|
})
|
|
226
247
|
const timer = setTimeout(() => p.kill(), 30_000)
|
|
227
248
|
const out = await new Response(p.stdout).text()
|
|
228
249
|
await p.exited
|
|
229
250
|
clearTimeout(timer)
|
|
230
|
-
return out.includes(
|
|
251
|
+
return out.includes(needle)
|
|
231
252
|
} catch {
|
|
232
253
|
return false
|
|
233
254
|
}
|
|
@@ -240,7 +261,7 @@ export const NO_SCHEDULER = {
|
|
|
240
261
|
remedy: "Install a release that has it first: bun add -g spinuptui@next --no-cache",
|
|
241
262
|
}
|
|
242
263
|
|
|
243
|
-
async function launchctl(...args: string[]): Promise<{ code: number; out: string }> {
|
|
264
|
+
export async function launchctl(...args: string[]): Promise<{ code: number; out: string }> {
|
|
244
265
|
const p = spawn(["launchctl", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
|
|
245
266
|
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
|
|
246
267
|
return { code: await p.exited, out: (out + err).trim() }
|
package/src/lib/schedulerJobs.ts
CHANGED
|
@@ -191,7 +191,6 @@ export function agentOf(header: JobHeader): "claude" | "codex" | null {
|
|
|
191
191
|
|
|
192
192
|
export interface JobState extends ScheduleState {
|
|
193
193
|
runs?: number
|
|
194
|
-
last_status?: string
|
|
195
194
|
last_report?: string
|
|
196
195
|
auth_alerted_for?: string
|
|
197
196
|
invalid?: string
|
|
@@ -222,7 +221,7 @@ export function saveState(s: Source, id: string, state: JobState): void {
|
|
|
222
221
|
export interface TickRun {
|
|
223
222
|
source: string
|
|
224
223
|
id: string
|
|
225
|
-
status: "ok" | "failed" | "timeout" | "error" | "auth" | "invalid" | "skipped"
|
|
224
|
+
status: "ok" | "failed" | "blocked" | "timeout" | "error" | "auth" | "invalid" | "skipped"
|
|
226
225
|
report?: string
|
|
227
226
|
notified?: string
|
|
228
227
|
}
|
|
@@ -235,6 +234,9 @@ export interface TickRecord {
|
|
|
235
234
|
errors: string[]
|
|
236
235
|
// Git sources another station runs (or released here): expected, not errors.
|
|
237
236
|
notRunHere?: string[]
|
|
237
|
+
// This dispatcher tick found the previous one still running, so it did nothing
|
|
238
|
+
// but say the station is alive.
|
|
239
|
+
stillRunning?: boolean
|
|
238
240
|
// The station heartbeat's ping at the end of this tick, when one is set.
|
|
239
241
|
heartbeat?: { ok: boolean; status: "up" | "down"; error?: string }
|
|
240
242
|
}
|
package/src/lib/schedulerRun.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// rather than counted as a run) carry over from the git-backed job runner; see
|
|
7
7
|
// docs/2026-10-20_scheduler-spec.md.
|
|
8
8
|
|
|
9
|
-
import { existsSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
10
10
|
import { hostname, homedir } from "node:os"
|
|
11
11
|
import { join } from "node:path"
|
|
12
12
|
import { loadConfig } from "../config.ts"
|
|
13
13
|
import { beaconFromConfig, beaconSend } from "./beacon.ts"
|
|
14
|
+
import { pageMarkup } from "./beaconListenInstall.ts"
|
|
15
|
+
import type { BeaconButton } from "../modules/contract.ts"
|
|
14
16
|
import { spawn } from "./spawn.ts"
|
|
15
17
|
import { prepareWorkDir } from "./home.ts"
|
|
16
18
|
import { fmt } from "./schedule.ts"
|
|
@@ -19,19 +21,24 @@ import { agentOf, DEFAULT_TIMEOUT_MIN, expandHome, loadState, runsDir, saveState
|
|
|
19
21
|
export const DEFAULT_ALLOW = "Read,Grep,Glob"
|
|
20
22
|
// Never allowed in a claude job, whatever its allow: line says.
|
|
21
23
|
export const ALWAYS_DENY = "Bash(git push *),Bash(git reset *),Bash(git clean *),Bash(rm -rf *),Bash(sudo *)"
|
|
22
|
-
|
|
24
|
+
// A script (or a `spinuptui` job) that exits with this ran but couldn't do its
|
|
25
|
+
// work because of something outside it: EX_TEMPFAIL. An agent says so in text.
|
|
26
|
+
export const BLOCKED_EXIT = 75
|
|
27
|
+
export const BLOCKED_RE = /^\s*STATUS:\s*blocked\b/im
|
|
28
|
+
export const AUTH_FAIL_RE =
|
|
23
29
|
/Failed to authenticate|OAuth (session|token)|could not be refreshed|Invalid API key|API key.*(expired|invalid)|Please run .?\/login|not (logged in|authenticated)/i
|
|
24
30
|
const REPORT_CAP = 200_000
|
|
25
31
|
// Tools a launchd PATH doesn't include by default.
|
|
26
32
|
const EXTRA_PATH = [join(homedir(), ".local/bin"), join(homedir(), ".bun/bin"), "/opt/homebrew/bin", "/usr/local/bin"]
|
|
27
33
|
|
|
28
|
-
export type RunStatus = "ok" | "failed" | "timeout" | "error" | "auth"
|
|
34
|
+
export type RunStatus = "ok" | "failed" | "blocked" | "timeout" | "error" | "auth"
|
|
29
35
|
|
|
30
36
|
function preamble(id: string, kind: string, createdBy: string, runs: string): string {
|
|
31
37
|
return `You are running unattended on ${hostname().split(".")[0]} as scheduled job \`${id}\` (kind: ${kind}), queued by: ${createdBy}.
|
|
32
38
|
Nobody is watching this run and nobody can answer questions. Do the task below, then stop.
|
|
33
39
|
Rules: report, don't act. Do not modify files outside ${runs}, do not push, deploy, post to clients or email anyone, unless the task explicitly says to.
|
|
34
40
|
Your final message is the report. Start it with a line \`SUMMARY:\` followed by one short paragraph (under 600 characters, plain text) that stands on its own in a chat message. Then give the details in Markdown.
|
|
41
|
+
If you couldn't do the work because of something outside this job that a person has to fix (a lapsed login, a missing permission, a service refusing you), put a line \`STATUS: blocked\` straight after the summary and say what's needed. Don't use it for a finding, however bad.
|
|
35
42
|
|
|
36
43
|
--- TASK ---
|
|
37
44
|
`
|
|
@@ -55,6 +62,15 @@ export function jobEnv(): Record<string, string> {
|
|
|
55
62
|
return env
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
// Starts a process that outlives this one: its own session (launchd reaps a
|
|
66
|
+
// job's process group, not this), and no inherited pipes, since whoever reads
|
|
67
|
+
// this process's stdout would otherwise wait for the child to finish too.
|
|
68
|
+
export function spawnDetached(cmd: string[], opts: { cwd: string; env: Record<string, string> }): void {
|
|
69
|
+
mkdirSync(opts.cwd, { recursive: true })
|
|
70
|
+
const proc = spawn(cmd, { cwd: opts.cwd, env: opts.env, stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true } as Parameters<typeof spawn>[1])
|
|
71
|
+
proc.unref()
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
export interface ProcResult {
|
|
59
75
|
code: number | null
|
|
60
76
|
output: string
|
|
@@ -112,6 +128,28 @@ export function summaryOf(job: Job, output: string): string {
|
|
|
112
128
|
return text.split("\n").slice(-15).join("\n").slice(-900)
|
|
113
129
|
}
|
|
114
130
|
|
|
131
|
+
export interface AgentRun {
|
|
132
|
+
agent: "claude" | "codex"
|
|
133
|
+
prompt: string
|
|
134
|
+
workdir: string
|
|
135
|
+
allow?: string
|
|
136
|
+
addDirs?: string
|
|
137
|
+
model?: string
|
|
138
|
+
sandbox?: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The adapters: a binary name and how to build its argv. Scheduler's agent jobs
|
|
142
|
+
// and Sentinel's worker share them. Returns why it can't run, as a string.
|
|
143
|
+
export function agentCommand(run: AgentRun, env: Record<string, string>): string[] | string {
|
|
144
|
+
const bin = Bun.which(run.agent, { PATH: env.PATH })
|
|
145
|
+
if (!bin) return `${run.agent} not found on PATH`
|
|
146
|
+
if (run.agent === "codex") return [bin, "exec", "-s", run.sandbox || "read-only", "-C", run.workdir, "--skip-git-repo-check", run.prompt]
|
|
147
|
+
const cmd = [bin, "-p", run.prompt, "--output-format", "text", "--permission-mode", "default", "--allowedTools", run.allow || DEFAULT_ALLOW, "--disallowedTools", ALWAYS_DENY]
|
|
148
|
+
for (const d of (run.addDirs ?? "").split(",")) if (d.trim()) cmd.push("--add-dir", expandHome(d.trim()))
|
|
149
|
+
if (run.model) cmd.push("--model", run.model)
|
|
150
|
+
return cmd
|
|
151
|
+
}
|
|
152
|
+
|
|
115
153
|
function buildCommand(job: Job, workdir: string, env: Record<string, string>, runs: string): string[] | string {
|
|
116
154
|
const h = job.header
|
|
117
155
|
const kind = h.kind!
|
|
@@ -119,13 +157,16 @@ function buildCommand(job: Job, workdir: string, env: Record<string, string>, ru
|
|
|
119
157
|
if (kind === "spinuptui") return [...selfCommand(), ...splitArgs(job.body)]
|
|
120
158
|
const agent = agentOf(h)!
|
|
121
159
|
const prompt = preamble(h.id!, agent === "codex" ? "agent/codex" : "agent/claude", h.created_by!, runs) + job.body
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return
|
|
160
|
+
return agentCommand({ agent, prompt, workdir, allow: h.allow, addDirs: h.add_dirs, model: h.model, sandbox: h.sandbox }, env)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// What a finished process means. An agent that exits 0 after saying it's blocked
|
|
164
|
+
// did no work, whatever its exit code says.
|
|
165
|
+
export function outcomeOf(agent: boolean, code: number | null, timedOut: boolean, output: string): RunStatus {
|
|
166
|
+
if (agent && code !== 0 && AUTH_FAIL_RE.test(output)) return "auth"
|
|
167
|
+
if (timedOut) return "timeout"
|
|
168
|
+
if (code === 0) return agent && BLOCKED_RE.test(output) ? "blocked" : "ok"
|
|
169
|
+
return !agent && code === BLOCKED_EXIT ? "blocked" : "failed"
|
|
129
170
|
}
|
|
130
171
|
|
|
131
172
|
export interface RunOutcome {
|
|
@@ -161,14 +202,13 @@ export async function runJob(source: Source, job: Job, occurrence: Date, now = n
|
|
|
161
202
|
const r = await runProcess(cmd, { cwd: workdir, env, timeoutMs })
|
|
162
203
|
code = r.code
|
|
163
204
|
output = r.output
|
|
164
|
-
status = r.timedOut
|
|
205
|
+
status = outcomeOf(!!agentOf(h), r.code, r.timedOut, r.output)
|
|
165
206
|
if (r.timedOut) output += `\n\n[scheduler] killed after ${timeoutMs / 60_000} minutes`
|
|
166
207
|
} catch (err) {
|
|
167
208
|
status = "error"
|
|
168
209
|
output = `could not start: ${(err as Error).message}`
|
|
169
210
|
}
|
|
170
211
|
}
|
|
171
|
-
if (agentOf(h) && status !== "ok" && AUTH_FAIL_RE.test(output)) status = "auth"
|
|
172
212
|
return finish(source, job, occurrence, started, new Date(), status, code, output, reportRel)
|
|
173
213
|
}
|
|
174
214
|
|
|
@@ -227,19 +267,25 @@ async function finish(
|
|
|
227
267
|
|
|
228
268
|
let notified: RunOutcome["notified"] = "skipped"
|
|
229
269
|
if ((h.notify || "beacon") !== "none") {
|
|
230
|
-
const icon = { ok: "✅", failed: "❌", timeout: "⏱️", error: "❌" }[status as Exclude<RunStatus, "auth">] ?? "•"
|
|
270
|
+
const icon = { ok: "✅", failed: "❌", blocked: "🚧", timeout: "⏱️", error: "❌" }[status as Exclude<RunStatus, "auth">] ?? "•"
|
|
231
271
|
notified = await notify(
|
|
232
272
|
`${icon} Job ${id} (${status}, ${duration(seconds)})\n\n${summaryOf(job, output) || "(no output)"}\n\nReport: ${source.name}/${reportRel}\nQueued by: ${h.created_by}`,
|
|
233
273
|
h.to,
|
|
274
|
+
status === "ok" ? undefined : [[runAgainButton(source.name, id)]],
|
|
234
275
|
)
|
|
235
276
|
}
|
|
236
277
|
return { status, report: reportRel, notified }
|
|
237
278
|
}
|
|
238
279
|
|
|
280
|
+
// A tap re-runs the job exactly as written: it can't add permissions.
|
|
281
|
+
export function runAgainButton(source: string, id: string): BeaconButton {
|
|
282
|
+
return { text: `Run ${id} again`, action: "scheduler.run", payload: { source, id } }
|
|
283
|
+
}
|
|
284
|
+
|
|
239
285
|
// In-process Beacon send. Never throws; returns "sent" or why it wasn't.
|
|
240
|
-
export async function notify(text: string, to?: string): Promise<string> {
|
|
286
|
+
export async function notify(text: string, to?: string, buttons?: BeaconButton[][], silent = false): Promise<string> {
|
|
241
287
|
try {
|
|
242
|
-
const r = await beaconSend(beaconFromConfig(loadConfig()), text, { to: to || null, silent:
|
|
288
|
+
const r = await beaconSend(beaconFromConfig(loadConfig()), text, { to: to || null, silent, ...(buttons ? { replyMarkup: pageMarkup(buttons) } : {}) })
|
|
243
289
|
return r.ok ? "sent" : `${r.reason}: ${r.message}`
|
|
244
290
|
} catch (err) {
|
|
245
291
|
return `error: ${(err as Error).message}`
|