cronjob implementation
Local platform lifetime
CronAdapter.connect({ signal, onConnectionState }) awaits initial journal pruning,
then starts its daily maintenance and scheduler intervals before reporting one
canonical state for cron:local: local-owner authority cron, unknown transport,
ready application, fresh observation and no retry. Core's
PlatformConnectionRuntime can then release interrupted-channel recovery.
This is local scheduler readiness, not delivery-provider health.
Host abort and disconnect() remove the abort listener, clear both intervals,
stop future job dispatch and abort managed guard execution. Already-owned model
turns retain their existing result-delivery ownership.
Aborting during initial pruning prevents timers and readiness from being published
when pruning settles. Initialization or report failure cleans up and rejects.
Project removal
This participant requires core 0.30.11 or later for durable removal acknowledgement and transaction-scoped active-Project admission. Update core before installing this plugin version.
JobStore.save requires active Projects for changed/new bound jobs via tx.host.projects.requireActive(id) and holds those locks through the final atomic JSON write in a short, nonretrying READ COMMITTED transaction. It performs no network I/O there and propagates file-write errors. Because admission yields, it rejects a changed file snapshot with cronjob.jobs_changed; a competing scheduler claim leaves that job for the winning generation or a later tick. Pure removals and unbound writes retain the existing synchronous owner boundary, so cleanup is not blocked by unchanged jobs in another deleting Project. Admission examines only stored job records; malformed rows retained by strict tool writes remain in the saved file without being interpreted as Project bindings. Journal settlement follows the file write and remains retryable.
The plugin declares provides.projectRemoval: "services". Its idempotent registerProjectRemoved callback removes jobs whose projectRef.projectId matches the deleting Project, preserving instance jobs and jobs bound elsewhere. It awaits store.save even when a retry finds no matching jobs, so a previous file write cannot hide unfinished journal settlement. Existing job deletion semantics retain claimed runs and history; core owns cancellation of Project work. Job creation, edits and execution reject lifecycle deleting; editing an unchanged Project binding still revalidates it.
Migration entry
The manifest entry exports migrate(db: PluginDb<never>): Promise<void> beside register.
The daemon loader always awaits it before registration for this reads: ['db'] plugin;
the DB CLI calls the same export without registering tools, routes, timers or services.
The export runs only the existing transactional plugin SQL through db.migrate.
A failed step rolls back its SQL and ledger entry and prevents registration. Repeated
calls skip applied versions. Runtime store construction never invokes migrations.
Tests that bypass the loader explicitly await this export before using their fixture.
Turn authority and conversation filing
Scheduling tools await the host's currentIdentity(), currentAccess() and isAdminSession()
readers before deciding job ownership, visibility, privileged options and execution project binding.
The shared access helpers and their callers are asynchronous through that boundary. Conversation
origin capture and private-room disclosure await the same verified identity, so a promise cannot
be mistaken for an anonymous caller or an absent project reference.
conversationLinks() awaits each immutable-key jobAssociation() lookup before filtering against
the host-authorized conversation ids. Job ACL, execution routing and delivery remain separate from
filing. The real-loader cron tool, ownership, wake-up and conversation-group suites cover these
asynchronous host contracts, including private titles that must remain hidden in shared rooms.
Problem audiences
Guard command exits, timeouts and output limits stay local. Project removal, revoked access, unavailable environments and nonzero project-command exits carry the core typed instance-condition cause. Guard runner defects retain cronjob.check_failed; scheduler, delivery and exhausted turn retries apply the same cause distinction before publisher reporting. Retry limits, log text, journal receipts and owner alerts are unchanged. This requires the core typed instance-condition seam exposed through elowen-plugin-shared/errors.
The host's notify rejects with an AggregateError retaining each adapter failure as a native cause. An all-instance aggregate remains local; one software failure remains reportable. A platform that has not reported readiness is not automatically an instance condition: an adapter missing its readiness callback is a software defect. Failed sends retain the queued payload and release its delivery lease; only a successful send removes it.
Retry reporting
The adapter uses ConsecutiveFailures from elowen-plugin-shared/consecutiveFailures; the plugin
declares core 0.30.11 and shared API 14. Recurring checks count per job; queued delivery counts per queued
item; periodic tick and journal maintenance each have their own counter. They export their existing
code only on failure three and reset after success. Delivery and tick classify typed instance
conditions before incrementing: those failures retain their local diagnostic without consuming or
resetting the software-failure threshold. For example, three Discord HTTP 503 failures followed by
three HTTP 400 failures without a successful send report the third 400 and keep the payload queued.
The tick applies the same rule when a journal claim cannot acquire a database connection and later
fails with a software defect. An overlapping or stopped tick skipped by the scheduler guard does not
reset the active episode. runCheck returns a typed failed flag so an
empty successful check resets the episode. One-shot checks and boot maintenance remain immediate.
Bounded turn retries stay local until the final failed attempt, which exports cronjob.turn_retry
once and retains the existing owner alert. A relay rejection with core's
native_checkpoint_conversion_failed code stops after the first attempt: the owner must repair
history/account access before trying again. That refusal remains a local warning and an error run
receipt, with the owner notified through the bell.
Owner alerts use ctx.alerts.raise with the required reporting: { kind: 'local-only' } classification.
The scheduler's own diagnostics decide which failures to export; the bell does not report them again.
A delivery failure is reported only for a successful non-quiet result or image that lacks host delivery
confirmation. A failed turn has no successful result to deliver, and an empty or quiet reply needs no
delivery. All keep their existing run-history and owner-alert behavior.
Bounded journal maintenance
Cronjob uses the asynchronous PluginDb from await ctx.db(). Its schema is the host-selected
plugin_cronjob; SQL quotes that identifier and qualifies every plugin table. Migration 7 creates
the final PostgreSQL runs and run_daily tables with explicit row_id identities. Installed ledgers
already record it, so it is never renumbered.
Maintenance uses drainDbRows(db, { schema: db.schema, table, identity: 'row_id', ... })
from elowen-plugin-shared/dbBatches. Its awaited beforeDelete(tx, ids) aggregates the selected
captured owners and deletes those exact identities on the same transaction connection. Selection
locks rows in identity order before aggregation. Both drains use the shared 64-row / 256 KiB limits,
process one oversized row alone and yield between committed batches. PostgreSQL octet_length
measures UTF-8 text, including multibyte previews.
openRunJournal().prune(now) returns a promise and drains expired detail in transactions of at most
64 rows and 256 KiB of stored text plus row overhead. An individual oversized historical row is processed
alone. The same transaction adds the selected runs to the owner-specific daily counters and deletes
exactly those runs, so interruption or overlapping maintenance cannot double-count. Daily expiry removes
at most 64 summaries and 256 KiB per transaction. Each nonempty batch yields through setImmediate; callers await completion
or log a rejected maintenance promise. CronAdapter.connect() owns initial pruning and the daily timer.
Retention horizons and account visibility remain unchanged.
removeUser(id) and removeMissingUsers(ids) return promises resolving to the combined number
of deleted detail and aggregate rows. They first clear removed initiators in batches of at most
64 explicit identities, then drain owned details and daily summaries with the same deletion budgets. The existing
initiator index is reused; null owners survive missing-account cleanup. Empty or unreadable account
lists retain the existing skip behavior.
Both account-removal and boot callbacks await the journal cleanup. Boot awaits the account directory, then retires missing-account jobs with the synchronous file write
before awaiting journal settlement. The host's 20-second reconcile budget logs failure and
raises an admin alert without cancelling work, so cleanup may finish after scheduler startup. Once journal cleanup starts, it does not
write the jobs file again and cannot overwrite a concurrent scheduler claim or newly saved job.
Each batch commits independently: a failed batch rolls back and rejects, earlier batches remain durable,
and repeating cleanup selects only rows still needing removal. No startup budget is increased.
tests/cronCleanup.test.ts covers thousands of details and aggregates, yields, failed-batch rollback,
resumption. tests/cronCleanupLifecycle.test.ts covers callback completion and the host deadline
with delayed database transaction acquisition.
Run claims and concurrent readers
A slot claim is one INSERT ... ON CONFLICT (claim_key) DO NOTHING RETURNING id. The losing caller
reads the committed winner and never dispatches it. Manual runs queued earlier reuse their durable
id; the scheduler must still win UPDATE ... WHERE outcome='waiting' before checking or relaying.
These database constraints apply equally to daemon and runner connections. Atomic JSON schedule
stamps retain their existing crash semantics, while the database owns cross-process execution deduplication.
Closing a run is a conditional terminal update that computes duration from the row's actual start. Job-save settlement may close only waiting manual rows, so a save racing a started guard cannot overwrite its result. Async claims recheck the current job owner before dispatch. Only authoritative registration reconciles abandoned executions, and registration awaits that reconciliation.
Run and summary pagination use read-only repeatable-read transactions for their count and page.
History and metadata keep their existing account ACL; substring searches use literal escaped ILIKE.
Owner profiles, grants, projects and immutable conversation associations await the live host readers.
No additional host capability is introduced.
Conversation-list history branch
The conversation-list mount requires core 0.30.7. The host mounts ConversationHistoryBranch inside a named
branch region below its conversation list item, never inside a table. The unchanged
parent/items/expanded/toggle/open props carry host-authorized job metadata and controlled expansion;
the plugin adds no reads, polling, pagination or persistence. The disclosure uses the shared ghost
Button and ChevronRight; its named ul contains one li per job with Clock and localized
enabled/paused badges. aria-controls names only the mounted list, with a unique id per renderer.
A retained run calls open("session:" + encodeURIComponent(sessionId) + ":" + flag), where
flag is 1 only for a continuable run and 0 otherwise. This requests navigation, not authority:
the host still decides whether the transcript is continuable. Each job has a required nullable run,
matching the core ConversationJobLink DTO. run: null means no readable retained run: the anchor
keeps the exact host-provided job-editor href and other jobs remain visible. A missing run is
invalid input, not an older DTO variant. Empty or invalid item arrays mount nothing;
loading, unavailable and read-error presentation belong to the host's independent branch state.
When no plugin renderer is available, the host provides its own list-compatible fallback.
The old table renderer is removed without a compatibility flag. Coordinate this plugin with the core 0.30.7 conversation-register cutover; older Cronjob browser bundles are not list-compatible. The separate Automation history table remains unchanged.
Verification uses the paired core candidate selected by ELOWEN_CORE_ROOT:
tests/cronjob-conversation-list.test.tsxchecks source and committed bundle registration through the candidate UI kit and host runtime. It covers list semantics, all three locales, mounted disclosure targets, encoded continuation flags, mixed retained/null-run jobs and exact editor links. Empty and malformed input checks keep a missingrundistinct from the validrun: nullDTO.tests/cronConversationGroups.test.tschecks the plugin-ownedcron.conversationLinkscontrol: authorized conversation IDs, ownership and run-target metadata. Core owns the later projection intoConversationJobLink, including whether a retained run is readable.tests/coreCompatibilityGate.test.tschecks that Cronjob refuses the old table host (core 0.30.6), admits the coordinated Project-removal host (0.30.11) and requires a capable paired candidate. Release tooling assigns the candidate's version; the gate still rejects an older artifact version.tests/releaseMetadata.test.mjsandtests/registryCatalog.test.mjscheck manifest/catalog/README version and compatibility agreement. Rebuilding only Cronjob withnpm run build:web -- cronjobmust leave its committedweb/index.jsandweb/index.cssunchanged.
The host catalog and CSS contracts (tests/contract/pluginBundleI18nKeys.test.ts and
tests/pluginWebCss.test.mjs) additionally require that candidate's completed web build; source-only
UI tests cannot replace them. The integrated conversation-list browser flow must load the candidate
bundle to verify real keyboard, touch and layout behavior.
UI invalidation
Maintainer note: creating, saving or deleting a job refreshes the jobs list, calendar, run history and host conversation links through the plugin's useInvalidateJobs hook. Run now from a job drawer, calendar card or failed-run result uses the same hook after the server accepts the request. The drawer owns its refresh, including an already-queued response; its callers do not repeat it. Calendar state labels and the History and result badges share web-src/runStatus.ts; calendar dots keep their own CSS tones. Detailed run receipts are retained for seven days, daily aggregates for ninety days, and result previews for up to 2,000 characters. These journal limits are fixed; the journal's optional clock is used by tests.
Browser settings contract
The job rail and creation dialogs remain available from both the Automation page and the plugin settings
deck. They share ScheduleField, ActiveHoursField and the existing conversation/destination adapters.
Simple schedule, interval-unit, active-hours, owner, preset and suggested-time choices use shared dropdowns;
the calendar's day/week navigation remains segmented. Presets fill an editable draft rather than binding a
job to a template, and custom times remain visible. For one-shot creation, daily and weekly presets may
fill a blank time through the existing schedule parser; interval presets leave the time for the user to choose.
Forms use the host's SettingsDocument, SettingsGroup and SettingsRow. Schedule and Advanced groups
start closed and persist through the host's folding mechanism. Saved-job keys are
cronjob:job:<id>:schedule and cronjob:job:<id>:advanced; creation keys are
cronjob:create:<lifecycle>:schedule and cronjob:create:<lifecycle>:advanced. Both entry points share
the same saved-job keys. Untitled leading fields and read-only run information do not become disclosures.
Groups stay mounted when closed, so folding does not reset a draft or change autosave.
Browser UI API 62 is required. Every single choice in the job forms (schedule mode, interval unit, weekday, active hours, owner and execution Project) is the host ChoiceField, typed as ElowenUiRuntime['components']['ChoiceField'] and always the anchored dropdown: the consumers pass no presentation prop, and because the published onChange receives a plain string, each schedule or owner callback narrows it to its own union where the options are declared. Saved values the options no longer list stay visible and selectable through the host's stored-unknown-value row.
The host's Input.unit displays interval amounts as min or h, and active-hour
clock values as h, using the host's shared numeric band. Schema millisecond limits display ms
and the shortest interval displays min, without a divisor or storage conversion. Count meaning belongs
in the localized label. Native input names, bounds and steps remain intact. Advanced cron validation stays
on the server; unrepresented schedules and stored active-hours expressions remain unchanged until edited.
Job edits retain revision-checked debounced autosave; creation retains explicit idempotent submission.
See the Scheduling Plugin page in the Elowen user manual.
Server module ownership
index.mjs composes the existing registrations in their original order. It opens the three atomic JSON
stores and the SQL run journal, registers account cleanup/reconciliation, connects the concrete readers
and writers below, then registers the platform adapter and shipped skills.
lib/scheduler.mjsownsCronAdapter, guard execution, due/manual claims, relay, owner alerts and leased pending deliveries. ItsjobRunLocationalso supplies calendar/navigation/retention reads. The host still injects the live timezone and owner-permission readers; these required inputs have no constructor defaults. The adapter keeps one sequential tick per generation and stops future work at teardown.lib/stores.mjsowns jobs, creation receipts and pending-delivery persistence, strict write reads, model-pair validation and the draft/creation rules shared by HTTP and tools. Job saves write atomic JSON before awaiting pending journal settlement; all callers await completion. Creation receipts and delivery leases retain their existing file format and ordering. Creation writes its receipt through the synchronous after-write callback before journal settlement yields, preserving retry deduplication.lib/runJournal.mjsremains the SQL authority for claims, run receipts and retention.lib/access.mjsowns account grants, owner limits, explicit tool scope and the actor visibility rule. Each execution rechecks permissions. Missing permission reads retain their existing fail-closed behavior.lib/filing.mjsowns immutable conversation associations, metadata-only conversation discovery and the unchangedcroncontrol methods,retainedSessionIdsandconversationLinks. Filing never changes execution or delivery. Missing and unavailable associations remain distinct.lib/routes.mjsregisters the complete jobs GET/PUT/POST/DELETE patterns. Authorization still precedes conflict payloads. After awaited validation and filing, a fresh strict read rechecks the target before the file write. Manual queueing also rechecks ownership after its awaited journal claim.lib/tools.mjsregisters the same five tools through the shared access, filing and write readers.lib/calendar.mjsowns job-response enrichment, the live engine-input reader and week/history/preview routes. Timezone, generation time, tick and lookback are read at request time; intentional preview overrides remain at their callers. Empty results and unreadable jobs still have separate responses.schedule.mjsowns parsing, due/slot decisions, wall-clock resolution, labels and schedule defaults.projection.mjsowns bounded forward expansion and arithmetic day summaries, importing that same parser, cron-day predicate, DST identity and active-hours gate. Projection is pure and performs no storage or network work; its existing candidate and day-slot caps remain unchanged.lib/constants.mjsholds only the shared id generator and JSON response constructor.
For example, a job-list GET filters with the access reader, enriches filing and next-occurrence data through the calendar reader, and projects from the same schedule authority used by the next scheduler tick. No additional host seam or public control/route contract is introduced.
Shared time-zone helpers
The server schedule engine and the scheduler import zonedParts, zonedTimeToMs and WEEKDAYS from
elowen-plugin-shared/zonedTime. The shared module owns the formatter cache, local wall-clock fields,
UTC conversion and Sunday-first weekday order. No local copies or re-exports remain in schedule.mjs.
The cron parser, slot identity, due logic and previews stay in this plugin and use those helpers.
This keeps the same time-zone and DST behavior while letting meeting-confirm use the same conversion.
The helpers do no network or storage work. An invalid zone retains the existing machine-zone behavior; schedules, catch-up and duplicate DST slots are unchanged. The manifest declares core 0.30.11 and shared API 14.
Authenticated API patterns
The plugin declares core 0.30.11 and shared API 14, supplied by the core's shared package 0.12.0.
Each method registers its complete pattern with its own catalog summary. The manifest declares
all 11 unique patterns, including the conversation reader in lib/filing.mjs.
| Method | Full path | Behavior |
|---|---|---|
| GET | /plugins/cronjob/jobs |
List visible jobs. |
| POST | /plugins/cronjob/jobs |
Create a job with an idempotent request ID. |
| PUT | /plugins/cronjob/jobs/:jobId |
Upsert one job with revision checks. |
| DELETE | /plugins/cronjob/jobs/:jobId |
Delete an authorized job idempotently. |
| POST | /plugins/cronjob/jobs/:jobId/run |
Queue a durable manual run. |
| GET | /plugins/cronjob/api/conversations |
List filing choices visible to the account. |
| GET | /plugins/cronjob/api/week |
Read the bounded week calendar. |
| GET | /plugins/cronjob/api/runs |
List retained runs or daily totals. |
| GET | /plugins/cronjob/api/runs/:runId |
Read one visible run receipt. |
| GET | /plugins/cronjob/api/operations |
List the account's unacknowledged manual runs. |
| GET | /plugins/cronjob/api/operations/:operationId |
Read the account's manual-run operation. |
| POST | /plugins/cronjob/api/operations/:operationId/acknowledge |
Acknowledge a finished owned operation. |
| POST | /plugins/cronjob/api/schedule-preview |
Validate a draft and preview its next occurrences. |
The host matches the entire declared pattern and decodes identifiers exactly once into
req.params.jobId, req.params.runId or req.params.operationId. Handlers receive an empty
req.path and never parse or decode a suffix. Unsupported methods and undeclared descendants
return 404 before a handler runs. Account grants, ownership, history visibility, manual-run
initiators, revision conflicts and acknowledgement rules remain enforced at their existing boundaries.
Route fixtures use the candidate's real PluginRegistry matcher. HTTP regressions cover job
writes with encoded identifiers and rejected descendants; operation regressions cover queue,
detail, acknowledgement and owner isolation through the complete declarations.
Developer reference
History retention and daily totals
GET /plugins/cronjob/api/runs?view=daily&from=2026-09-01&to=2026-09-30 returns { dailySummaries, total }. Each summary contains jobId, jobName, captured ownerUserId, localDate, timezone, ok, error, skipped and total. It uses the same actor visibility and owner/date/job/search/outcome filters as run history, but searches only retained job names and uses limit/offset pagination, capped at 100 rows. An outcome filter selects summaries containing that outcome; all counters remain the day's totals. Without view=daily the existing detailed run list and cursor contract remain. Empty retained history returns an empty summary list. HistoryTab requests both views with independent pagers and never offers run, transcript or result actions for summary rows. Detailed terminal runs older than seven days become summaries retained for 90 days. countsByDay adds both stores without counting pruned details twice.
Captured aggregate ownership
The PostgreSQL daily journal is unique by job, local date and captured owner, treating NULL as the instance bucket. Pruning groups by the captured owner and never overwrites another owner's counters on a transfer. Migration applies through the host plugin DB migrator at load; it never needs a live-DB maintenance script. Historical rows retain the ownership recorded by schema 3; prior overwritten attribution cannot be reconstructed from aggregates. HistoryTab is the reader.
Shared live schedule inputs and preview refusal
createJobProjection awaits the live host timezone and reads generation time plus the active CronAdapter's tickMs and cronLookbackMs. Its liveEngineInputs and next-occurrence projections are asynchronous; calendar routes await them before expanding slots. The scheduler, manual runs, wake-up tool and local-time draft builders likewise await the same timezone resolver, preserving non-UTC and DST wall-clock identities. Daily/weekly due checks and forward projections share schedule.mjs localSlotInstant: the spring gap has no occurrence and a repeated fall hour chooses its earlier instant once. Historical week cards override the reference time to just before their local day; draft previews otherwise use the same live policy as the scheduler. POST schedule-preview reads only schedule and expands forward from the current instant, returning at most five upcoming occurrences. If planOccurrences exhausts its 100000-candidate budget, the route returns HTTP 422, code preview_budget_exhausted and no occurrences. Invalid grammar remains valid:false with its existing response contract. The advanced editor displays the host API error; valid ordinary modes keep their existing controls. No browser cron expansion is introduced.
Scoped command execution
Persisted jobs and create/edit API payloads accept only host and managed Project references, as
validated by executionRef; a managed reference requires projectId, while the administrative
host reference may omit it. Browser CronJob and CronJobCreateBody select these two variants
from the shared ProjectExecutionRef using its kind discriminator. JobDrawer uses that wire
type for its stored execution choice, and /projects supplies only host/managed options. Adding a
local CLI variant to the shared execution type does not make local targets valid cron schedules.
The type narrowing changes no persisted data, request, rendering, or runtime validation.
Cronjob's projectCheck calls the live Sandbox control's runCommand with leaseKind cron, the existing projectRef and explicit account/root actor, the guard timeout/signal and a combined 1 MiB output limit. Core owns capture, cancellation, heartbeat, sanitization and lease release. A missing Sandbox control fails the guard; nonzero or signal exits remain failed checks and producer errors, including cleanup failures, remain errors. Host target and owning-account access are checked at authorization and again before the guard. The scheduler is the consumer.
Cron guard fixtures share tests/helpers/managedSession.ts::childSession with managed LSP tests. Fake EventEmitter children emit stdout/stderr EOF on close; real ChildProcess instances own their pipes. This preserves the host runner's output-drain requirement, partial-output assertions and exactly-once cancellation and lease-release checks.
Shared filing, local times and grammar
CronAdd and HTTP recurring creation use createJobWrites.buildRecurringJob and the existing associationEdit filing authority. A tool supplies its current project binding explicitly even for an instance job; HTTP instance creation remains unbound. Personal creation preserves its existing binding. Direct-delivery origins are supplied only by the tool path and remain independent of filing. createJobWrites.localRunAt is the one create/edit mapper for local one-shots, preserving spring-gap errors and earlier/later instants. ACTIVE_HOURS_PATTERN in scheduleGrammar is shared by daemon active-hour validation and the browser builder; historical malformed-hour handling is unchanged.
Public response cuts
Run-history rows no longer expose requestId or initiatorUserId; operation views and persisted audit/dedupe columns keep them. Week responses omit generatedAt, nowLocalTime, precisionMs and truncated; scheduler exposes only ready. Interval rows retain jobId, schedule, enabled, nextLocalDate and nextLocalTime, removing nextExpectedAt, remainingToday, lastOutcome and lastRunAt. Detailed nextOccurrence keeps expectedAt. planOccurrences returns occurrences/truncated while candidate counting stays private. summarizeJobDay returns exact remaining/head without kind/truncated, bounded to at most 1440 fixed wall clocks and a short head. resolveLocalDateTime returns ms or its existing error; later disambiguation remains. The unused isDue wrapper is removed; dueSlot is the scheduler authority.
Host browser and operation seams
Cronjob uses ElowenUiRuntime.api directly with the published JSON option for create, save, run and preview requests, and its published utils.interpolate type for all existing interpolated consumers. No local serializer or fallback is added. Modal callers no longer pass intent. useRunCronJob returns Promise, retaining accepted-id validation, dock opening, toast and invalidation. Dock presentation uses the existing skipReason and runSkipped translation for terminal skipped operations; no outcome wire field is added. Without skipReason the existing status label remains. Manual HTTP initiators use operationInitiatorFromCredentialScope from the shared package: full/impersonation map to ui, api/advisor to system, agent to agent.