Skip to content

feat(studio): let the Schedules panel author and audit schedules - #616

Closed
jtenniswood wants to merge 1 commit into
feat/studio-enterprise-uifrom
feat/studio-schedule-authoring
Closed

jtenniswood wants to merge 1 commit into
feat/studio-enterprise-uifrom
feat/studio-schedule-authoring

Conversation

@jtenniswood

Copy link
Copy Markdown
Contributor

Why

The panel called itself the oversight surface for scheduled tasks but covered only half the daemon's API: list, pause, resume, fire, delete. A schedule had to be created outside the UI, could not be changed once armed, and its past fires were invisible — so the one place an operator looks to understand unattended work could not tell them what it had done.

What

  • Create (POST /v1/schedules) and edit (PUT /v1/schedules/{name}) through one form: name, prompt, cron cadence with IANA timezone and total-fire cap or a one-shot with an optional crash-retry budget, tool surface + workspace, the write opt-in, and per-fire turn/tool caps. The name is fixed on an edit because a schedule is addressed by name — renaming would arm a second one and drop the firing history.
  • Fire history (GET …/fires), newest first, with each fire's stop reason, its error when it failed, and the sched-- session it ran as. An in-flight fire re-reads on its own (GET …/fires/{id}) rather than refetching the log.
  • The empty state no longer implies schedules can only be authored by the agent.

Two rules the UI has to respect

Both are now in studio/CLAUDE.md under "Rules that have teeth", and both are pinned by tests:

  1. An update REPLACES the spec — the daemon preserves only the firing state, created_at, and the captured owner, so any field the form omits is deleted. ScheduleRow.carried carries what this form has no control for (provider selector, misfire policy, singleton, carried context, per-fire deadline, multimodal parts) and encodeScheduleSpec puts it back. Without that, editing a prompt would silently drop a selector an operator set from the CLI.
  2. A request body is not a response body — requests decode with protojson, responses encode with stdlib encoding/json, and the two disagree on every well-known type (a Timestamp reads back as {seconds,nanos} but must be sent as RFC 3339; a Duration reads back as {seconds} but must be sent as "900s"). The encoder is that conversion, which is why the edit path decodes to a view model first instead of echoing raw JSON.

The mutating / mode pair is coupled in the form because the daemon rejects a non-mutating schedule with a write-capable posture, so an invalid pair is not constructible rather than being caught by a 400. Everything else the create-seam enforces — cron grammar, the cadence floor, the selector inventory, name collisions — is surfaced verbatim rather than re-guessed client-side.

No ADR: this is a scope correction inside accepted ADR 0225, which already names studio/CLAUDE.md as the module's invariant home. No Go, proto, or engine change; llms.txt is byte-unchanged.

Verification

task studio:test (8/8), task studio:lint, task studio:typecheck, go test ./docs/lint, matlatl check . --strict (0 broken links), task site:build — all green.

Driven against a real offline mecated (--mock --store-dir), through the browser UI:

  • a schedule created out of band carrying a misfire policy, carried context, a 900s fire timeout and per-fire limits survived a prompt-only edit from the UI with its fire count, next fire, and every unexposed field intact;
  • both trigger arms created (datetime-local → RFC 3339 UTC verified across the BST offset), a fire ran, and its record rendered in the log;
  • the refusals surface verbatim: non-mutating + default mode, a sub-minute cadence, invalid cron grammar, a missing workspace, and a duplicate name.

Note for whoever merges second

This does not merge cleanly with #615 (server-backed chat list) — 7 conflict hunks across studio/lib/protocol.ts, studio/app/page.tsx, studio/tests/rendered-html.test.mjs, studio/CLAUDE.md, and user-docs/what-you-get/studio.md. Most are union resolutions, but two are not:

  • the test-file and page.tsx import lists both name decodeScheduleRows and parseMecatlEvent, so keeping both sides duplicates them and tsc fails;
  • in studio/tests/rendered-html.test.mjs the conflict boundary cuts through this branch's last test, leaving its closing }); after the other branch's appended block — a plain union produces SyntaxError: Unexpected end of input, which reads as a syntax error rather than a merge error;
  • studio/CLAUDE.md's lib/protocol.ts Shape bullet is rewritten by both sides and needs one merged sentence naming the chat inventory and the schedule encoder — picking a side loses half the file's contents.

The resolution has been verified locally (17/17 combined tests, lint, typecheck, strict link check) and can be reproduced from the above.

🤖 Generated with Claude Code

The panel called itself the oversight surface for scheduled tasks but
covered only half the daemon's API: list, pause, resume, fire, delete. A
schedule had to be created outside the UI, could not be changed once
armed, and its past fires were invisible — so the one place an operator
looks to understand unattended work could not tell them what it had done.

It now covers the rest of the surface:

- Create (POST /v1/schedules) and edit (PUT /v1/schedules/{name}) through
  one form. The name is fixed on an edit because a schedule is addressed
  by name — renaming would arm a second one and drop the firing history.
- Fire history per schedule (GET …/fires), newest first, with each fire's
  stop reason, its error when it failed, and the sched-- session it ran
  as. An in-flight fire re-reads on its own (GET …/fires/{id}) rather
  than refetching the log.

Two rules the UI has to respect, both now documented in studio/CLAUDE.md
and pinned by tests:

- An update REPLACES the spec, so any field the form omits is DELETED.
  ScheduleRow.carried carries the settings this form has no control for
  (provider selector, misfire policy, singleton, carried context, per-fire
  deadline, multimodal parts) and encodeScheduleSpec puts them back.
- A request body is not a response body: requests decode with protojson,
  responses encode with stdlib encoding/json, and the two disagree on
  every well-known type (Timestamp, Duration). The encoder is that
  conversion, which is why the edit path decodes to a view model first.

The mutating/mode pair is coupled in the form because the daemon rejects a
non-mutating schedule with a write-capable posture, so an invalid pair is
not constructible rather than being caught by a 400. Everything else the
create-seam enforces — cron grammar, the cadence floor, the selector
inventory, name collisions — is surfaced verbatim rather than re-guessed.

Verified against a real offline mecated (--mock, --store-dir): an
out-of-band schedule carrying a misfire policy, carried context, a 900s
fire timeout and per-fire limits survives a prompt-only edit from the UI
with its firing state intact, and both trigger arms create, fire, and
render their log.

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

Copy link
Copy Markdown
Contributor Author

Superseded by #615 — these commits were merged into that branch at the repository owner's direction, so both Studio changes land as one PR. Nothing here is dropped: af4d9df8 is in #615 unchanged, alongside the merge resolution.

🤖 Generated with Claude Code

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