Skip to content

feat(cron): insta cron — manage schedules from the command line - #258

Open
v01dstar wants to merge 1 commit into
mainfrom
feat/cron-jobs
Open

v01dstar wants to merge 1 commit into
mainfrom
feat/cron-jobs

Conversation

@v01dstar

@v01dstar v01dstar commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

insta cron — list, create, show, edit, pause, resume, delete, run, runs, preview.
Needs InsForge/instacloud-platform#480, which serves these endpoints.

The CLI is the surface an agent has, so this is the whole cron API rather than a read-only view.
The platform PR adds the governance entries that make agent mode work; without them every mutation
here returns 403.

Four things that carry meaning

A job is addressed by name here and by id in the API, so every verb resolves through a
list-and-match, the way resolve-service.ts does. Names are unique per project and branch, which is
what makes that safe.

Idempotency-Key is minted once per invocation, never per HTTP attempt. It is the whole reason
a retried trigger cannot fire the job twice; moving it inside a retry loop would silently undo that.

If-Match carries the revision just read, and a 409 is reported as "the schedule changed
underneath you" — re-reading and retrying would clobber whatever the other writer did.

Times print as UTC with the marker visible. A cron pinned to UTC does not keep a fixed local
time, so a localised column would quietly be wrong twice a year.

Smaller calls

show lists header NAMES and says values are not returned — they are encrypted at rest and the API
never hands them back, so an empty value column would read as "no value set" rather than "not
shown".

runs reports status, trigger, attempts, the wake/request split and the HTTP status: the same facts
the console shows, because this is where an operator looks when a schedule misbehaves and they have
no browser.

delete refuses without --yes and says what deletion does and does not destroy (the schedule
stops immediately; run history is retained).

Verified

tsc --noEmit and npm run build clean. Full suite 80 files / 1657 tests passing, including 45
new ones. This repo has no lint config — CI is tsc + vitest, so there was nothing to run.

Driven end to end against a locally booted platform on the production entrypoint: preview, create,
list, run, runs, pause, resume, delete, with the run landing in Postgres and a real https target
answering 200. Agent mode exercised with a real enrolled session across three policy modes — every
verb executes under full_access, returns an approval id under branch_specific, and is denied by
name under read_only.

One behaviour worth seeing in the output rather than the diff: pointing a job at
http://127.0.0.1:… is refused as blocked (scheme_not_allowed). The production entrypoint does
not set allowInsecure, so loopback and plain http are refused by design — the CLI reports that
refusal rather than hiding it.

🤖 Generated with Claude Code


Summary by cubic

Adds insta cron, the full cron API from the command line: list, create, show, edit, pause, resume, delete, run, runs, and preview. Requires the platform endpoints in InsForge/instacloud-platform#480 — without its governance entries every mutation returns 403.

Behavior

  • Jobs are addressed by name here and by id on the wire; every verb resolves name → id from the branch listing.
  • run mints one Idempotency-Key per invocation, outside the retry loop, so a retried request cannot fire the job twice.
  • Edits send If-Match with the revision just read; a 409 says the schedule changed and nothing was written, instead of re-reading and clobbering.
  • Every time prints as UTC with the Z visible, because a UTC-pinned schedule does not hold a fixed local time.
  • show lists header names only — values are write-only; edit replaces the request and warns which headers it drops.
  • delete refuses without --yes; run history is retained, and a manual run is an extra execution that leaves the next scheduled tick in place.
  • Plain-http and loopback URLs are refused by the platform as scheme_not_allowed; the CLI reports the refusal.

Written for commit a3b2e2f. Summary will update on new commits.

Review in cubic

The CLI is the surface an agent has, so this is the whole cron API and not a
read-only view: list, create, show, edit, pause, resume, delete, run, runs, and
preview.

Four things here carry meaning rather than plumbing:

  A job is addressed by NAME on the command line and by id in the API, so every
  verb resolves through a list-and-match the way resolve-service.ts does. Names
  are unique per project and branch, which is what makes that safe.

  Idempotency-Key is minted once per invocation, never per HTTP attempt. It is
  the whole reason a retried trigger cannot fire the job twice, and moving it
  inside a retry loop would silently undo that.

  If-Match carries the revision just read. A 409 means the schedule changed
  underneath you and is reported as that — re-reading and retrying would clobber
  whatever the other writer did.

  Times print as UTC with the marker visible. A cron pinned to UTC does not keep
  a fixed local time, so a localised column would quietly be wrong twice a year.

`show` lists header NAMES and says values are not returned: they are encrypted
at rest and the API never hands them back, so an empty value column would read
as "no value set" rather than "not shown".

`runs` reports status, trigger, attempts, the wake/request split and the HTTP
status — the same facts the console shows, because this is where an operator
looks when a schedule is misbehaving and they have no browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/commands/cron.ts">

<violation number="1" location="src/commands/cron.ts:286">
P2: When editing a job that currently has a POST body, supplying only `--header` or `--method` silently replaces it with a GET or body-less request. Reject incomplete request replacements or require an explicit body/clear-body choice, and warn that the existing payload will be discarded.</violation>

<violation number="2" location="src/commands/cron.ts:522">
P3: When `cron run` targets a paused job, it falsely says the next scheduled run still happens. Report that the job remains paused instead of claiming a scheduled execution will occur.</violation>
</file>

<file name="test/cron.test.ts">

<violation number="1" location="test/cron.test.ts:263">
P1: This test does not exercise the replay guarantee named in its title. Simulate the real 401/refresh/retry path or assert every trigger POST uses the same key and occurs only once, otherwise a duplicate manual execution can regress unnoticed.</violation>

<violation number="2" location="test/cron.test.ts:326">
P2: The conflict test does not verify its no-re-read guarantee. Assert that the listing GET occurs exactly once, in addition to checking the single PATCH attempt.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread test/cron.test.ts

describe('cron run (manual trigger)', () => {
it('mints ONE Idempotency-Key for the invocation, so a replayed request cannot fire the job twice', async () => {
const { ctx, calls } = stub((c) => (c.path.endsWith('/runs') ? { status: 202, body: { runId: 'run9' } } : listResponse))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This test does not exercise the replay guarantee named in its title. Simulate the real 401/refresh/retry path or assert every trigger POST uses the same key and occurs only once, otherwise a duplicate manual execution can regress unnoticed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/cron.test.ts, line 263:

<comment>This test does not exercise the replay guarantee named in its title. Simulate the real 401/refresh/retry path or assert every trigger POST uses the same key and occurs only once, otherwise a duplicate manual execution can regress unnoticed.</comment>

<file context>
@@ -0,0 +1,367 @@
+
+describe('cron run (manual trigger)', () => {
+  it('mints ONE Idempotency-Key for the invocation, so a replayed request cannot fire the job twice', async () => {
+    const { ctx, calls } = stub((c) => (c.path.endsWith('/runs') ? { status: 202, body: { runId: 'run9' } } : listResponse))
+    const log = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
+    try {
</file context>

Comment thread src/commands/cron.ts
/** The stored request, from the flags alone. `undefined` when no flag shaped one. */
export function buildRequest(o: RequestOpts): { method: CronMethod; headers?: Record<string, string>; body?: string } | undefined {
if (!namesRequest(o)) return undefined
const method = o.method ? parseMethod(o.method) : o.body !== undefined ? 'POST' : 'GET'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When editing a job that currently has a POST body, supplying only --header or --method silently replaces it with a GET or body-less request. Reject incomplete request replacements or require an explicit body/clear-body choice, and warn that the existing payload will be discarded.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/cron.ts, line 286:

<comment>When editing a job that currently has a POST body, supplying only `--header` or `--method` silently replaces it with a GET or body-less request. Reject incomplete request replacements or require an explicit body/clear-body choice, and warn that the existing payload will be discarded.</comment>

<file context>
@@ -0,0 +1,567 @@
+/** The stored request, from the flags alone. `undefined` when no flag shaped one. */
+export function buildRequest(o: RequestOpts): { method: CronMethod; headers?: Record<string, string>; body?: string } | undefined {
+  if (!namesRequest(o)) return undefined
+  const method = o.method ? parseMethod(o.method) : o.body !== undefined ? 'POST' : 'GET'
+  // A body on a GET is a typo with a plausible-looking outcome: the platform would send it and most
+  // targets would ignore it, so the job would run "fine" and do nothing. `--body` alone implies
</file context>

Comment thread test/cron.test.ts
expect(said).toContain('changed since revision 4')
expect(said).toContain('nothing was written')
// One PATCH, and no second read to "resolve" the conflict.
expect(calls.filter((c) => c.method === 'PATCH')).toHaveLength(1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The conflict test does not verify its no-re-read guarantee. Assert that the listing GET occurs exactly once, in addition to checking the single PATCH attempt.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/cron.test.ts, line 326:

<comment>The conflict test does not verify its no-re-read guarantee. Assert that the listing GET occurs exactly once, in addition to checking the single PATCH attempt.</comment>

<file context>
@@ -0,0 +1,367 @@
+      expect(said).toContain('changed since revision 4')
+      expect(said).toContain('nothing was written')
+      // One PATCH, and no second read to "resolve" the conflict.
+      expect(calls.filter((c) => c.method === 'PATCH')).toHaveLength(1)
+    } finally {
+      err.mockRestore()
</file context>

Comment thread src/commands/cron.ts
info(`triggered cron job ${job.name} — run ${runId} accepted (read it with \`insta cron runs ${name}\`)`)
// A manual run is an EXTRA execution: the platform does not move next_run_at for it, and an
// operator firing a job by hand at 02:59 should not expect the 03:00 tick to have been consumed.
info(` the scheduled run at ${fmtUtc(job.next_run_at)} still happens — a manual run is an extra execution`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When cron run targets a paused job, it falsely says the next scheduled run still happens. Report that the job remains paused instead of claiming a scheduled execution will occur.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/cron.ts, line 522:

<comment>When `cron run` targets a paused job, it falsely says the next scheduled run still happens. Report that the job remains paused instead of claiming a scheduled execution will occur.</comment>

<file context>
@@ -0,0 +1,567 @@
+  info(`triggered cron job ${job.name} — run ${runId} accepted (read it with \`insta cron runs ${name}\`)`)
+  // A manual run is an EXTRA execution: the platform does not move next_run_at for it, and an
+  // operator firing a job by hand at 02:59 should not expect the 03:00 tick to have been consumed.
+  info(`  the scheduled run at ${fmtUtc(job.next_run_at)} still happens — a manual run is an extra execution`)
+}
+
</file context>
Suggested change
info(` the scheduled run at ${fmtUtc(job.next_run_at)} still happens — a manual run is an extra execution`)
info(job.enabled
? ` the scheduled run at ${fmtUtc(job.next_run_at)} still happens — a manual run is an extra execution`
: ' the job is paused — this manual run does not resume its schedule')

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant