Adds note scheduling and NIP-37 opt-in encrypted draft sync to Amethyst Desktop, and extracts the existing Android scheduled-post code into `commons` so both platforms (and PowJobRestorer) share one implementation. - Compose → clock icon → date/time picker (presets + exact-minute); the note is pre-signed and stored locally, then published at its time. - Publishes while the app is open (45s in-app tick + launch catch-up) AND while fully closed: an OS job (launchd / schtasks / systemd, registered only while the queue is non-empty) relaunches the binary in a headless, key-free `--publish-scheduled` mode that opens a websocket and pushes the pre-signed bytes. - A "Scheduled" deck destination (tabs Scheduled / Drafts / Articles): status, cancel, publish-now, edit (cancel + reopen prefilled). - Drafts: save-as-draft with a default-OFF "Sync across devices (encrypted)" toggle publishing a NIP-37 DraftWrapEvent (kind 31234, NIP-44 to self); drafts sync down on a fresh device. Extraction / de-dup: ScheduledPost → commons/commonMain; ScheduledPostStore + ScheduledPostPublisher → commons/jvmAndroid (Jackson/java.io.File are gate-forbidden in commonMain). The commons store is a strict superset of upstream's parallel Android store (account-scoped claim, CLAIM_TTL crash recovery, PUBLISHING-only status guards, reload-before-claim); upstream's new ScheduledPostWorkGate gating is adopted to drive it. Single-writer file lock + reload-before-claim so the in-app timer and headless process never double-publish. Store file 0600, dir 0700. macOS verified on the packaged app-image (compose+schedule, in-app publish, app-closed launchd firing, Scheduled screen, NIP-37 draft round-trip). Windows/Linux OS-integration authored but untested; headless has no Tor routing yet — both documented in the PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
36 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| Desktop Note Scheduling & "Drafts & Scheduled" Screen | feat | completed | 2026-07-10 | docs/brainstorms/2026-07-09-feat-desktop-note-scheduling-brainstorm.md |
✨ Desktop Note Scheduling & "Drafts & Scheduled" Screen
Overview
Bring note scheduling and a unified "Drafts & Scheduled" screen to Amethyst Desktop. A clock icon in the composer opens a date/time picker; scheduled notes are pre-signed at schedule time and stored locally, then published at their scheduled moment by an OS-level job so they fire even when the desktop app is fully closed. A single sidebar destination lists Drafts and Scheduled notes with manage actions.
Amethyst Android already ships full scheduling + NIP-37 drafts — this is a
Desktop port that extracts Android's pure logic into commons/, replaces
Android's WorkManager background layer with an OS-scheduler + amy publisher, and
adds Desktop-native UI. (see brainstorm: docs/brainstorms/2026-07-09-feat-desktop-note-scheduling-brainstorm.md)
Problem Statement / Motivation
Desktop users cannot schedule notes or manage drafts from a composer. Android has
had this since the scheduledposts/ service landed. The gap is Desktop-only UI +
a Desktop background-publish mechanism (Desktop has no WorkManager equivalent).
The crux insight: because the scheduled event is pre-signed and its JSON is stored, the process that fires at time T is a dumb pipe — it opens a websocket and pushes already-signed bytes. It never touches the signing key. This makes an OS-level "publish even when app is closed" mechanism safe and small.
Proposed Solution
- Extract Android's pure scheduling code (
ScheduledPost,ScheduledPostStore, the drain/publish loop, time-preset utils,ScheduleAtButton/Pickerlogic) intocommons/so Desktop, Android, andamyshare one implementation. - Compose integration (Desktop): add a clock icon +
ScheduleAtPickertoComposeNoteDialog. On "schedule", pre-sign the event (block if a bunker signer is offline), store it locally. - Publish mechanism (hybrid, lifecycle-managed):
- In-app timer fires due posts instantly while the app is open (re-resolves write relays via NIP-65 outbox).
- OS-level recurring job (every ~5 min →
amy publish-scheduled) is registered when the queue goes 0→1 and cancelled when it drains to 0. Fires when the app is closed. Both paths dedup via the store's atomicclaimDuePosts().
amy publish-scheduled— new headless, key-freeclisubcommand that drains due pre-signed posts and publishes them.- "Drafts & Scheduled" screen — one Desktop sidebar destination, two tabs (Drafts | Scheduled), rows with preview/time/status and actions.
- Overdue posts auto-publish on next app launch / next OS-job tick.
Key decisions carried from brainstorm
| Decision | Choice | Source |
|---|---|---|
| Target | Desktop port (Android = reference) | brainstorm §Key Decisions |
| Publish model | OS-level, lifecycle-managed single recurring job + in-app timer | brainstorm §1 |
| Publisher | amy publish-scheduled (key-free, pre-signed) |
brainstorm §2 |
| Signing | Pre-sign at schedule time | brainstorm §3 |
| Bunker offline at schedule | Block with a message | brainstorm §Resolved |
| Overdue posts | Auto-publish on next launch/tick | brainstorm §4 |
| Drafts storage | Local + opt-in NIP-37 sync | brainstorm §5 |
| Relay drift | Re-resolve at publish (best-effort; see wrinkle below) | brainstorm §Resolved |
| OS coverage v1 | All three (macOS launchd / Windows schtasks / Linux systemd) | brainstorm §6 |
| Screen shape | One screen, two tabs | brainstorm §7 |
| Editing | Cancel + recompose | brainstorm §8 |
| Draft sync UX | One list, small "synced" badge | brainstorm §Resolved |
Locked Decisions (post-deepen, user-confirmed 2026-07-10)
- Single all-in PR — implement all phases together (user chose against staging). The 3-PR table below is retained only as a risk map / suggested commit sequence within the one PR.
- App-closed mechanism = thin OS trigger → headless mode of the desktop binary.
The OS job (launchd/schtasks/systemd) launches the desktop app in a
--publish-scheduledheadless mode that reuses the commons publisher against the same~/.amethyst/store. Not the separateamybinary (no data-dir mismatch, no second bundle, no keychain-on-tick).amy publish-scheduledis out of scope for this PR (may be added later as CLI parity). - Single-writer discipline guards the store: the GUI in-app timer and the
OS-triggered headless process must never drain concurrently (lockfile/heartbeat so
one yields). Keeps the JSON store; SQLite migration not required. Still add the
CLAIM_TTLstuck-PUBLISHINGrecovery + account-scoped claim in Phase 0.
Enhancement Summary (deepened 2026-07-10)
Deepened with 8 parallel research/review agents (architecture, security, simplicity, KMP-placement, amy-CLI, cross-process-store, Compose-picker, OS-scheduler). Three independent reviewers (architecture, security, simplicity) converged on the same verdict: the extraction is sound, but the two-process OS-publisher design is the risky, over-engineered pillar. Key changes below; details in "Research Insights".
Key improvements
- Staged into 3 PRs. PR1 (Phases 0–2 + Scheduled tab) delivers the core value with ~zero HIGH risks. PR2 adds app-closed OS firing. PR3 adds NIP-37 draft sync.
- Publish mechanism reconsidered. The recommended app-closed mechanism is now a
thin OS trigger that launches a headless publish mode of the desktop binary
(single-writer, same
~/.amethyst/dir, real relay re-resolution, no second bundled binary, no keychain reads on tick) — withamy publish-scheduledkept as optional CLI/interop parity, not the production path. This collapses four HIGH risks at once (cross-process race, data-dir mismatch, amy bundling, keychain exposure). - Two latent correctness bugs found in the existing store (inherited by Android
too): (a) no cross-process safety in
claimDuePosts(in-processMutexonly), (b) no timeout recovery for stuckPUBLISHINGrows → silent permanent drop. Both must be fixed in Phase 0. - KMP placement corrected — Jackson +
java.io.Fileare gate-forbidden incommonMain(verifyKmpPurity). Store/publisher go injvmAndroid, not commonMain.OsSchedulerexpectinjvmAndroid, not commons/jvmMain. - Security hardening added to acceptance criteria (0600 perms, absolute/canonical
trigger path, argv-arrays not
sh -c, strict plist/unit escaping, key-free path).
New considerations discovered
- Material3
DatePicker/TimePickerdo work on Compose Desktop (CMP 1.11.0) — adapt Android's picker inside aDialog(notAlertDialog); no custom picker needed. claimDuePostsis not account-scoped — with a single multi-account file, the amy/headless path could publish account B's event under account A's relays. Add an account-scoped claim.- Relays commonly reject
created_attoo far in the future — pre-signing next week's post may be refused at publish; handle as FAILED, and bound stale-overdue auto-publish (don't blast weeks-old content silently).
The phase details below are superseded by the "Research Insights & Revised Plan" section wherever they conflict. The original phases are kept for provenance.
Reuse / Extract / New Matrix
| File/Component | Status | Location | Action |
|---|---|---|---|
ScheduledPost model + ScheduledPostStatus |
📦 Extract (PURE) | amethyst/.../service/scheduledposts/ |
Move to commons/commonMain |
ScheduledPostStore (Jackson+Mutex+atomic, StateFlow) |
📦 Extract | same | Move to commons/jvmAndroid (Jackson+File gate-forbidden in commonMain); inject File, NO expect/actual. Add CLAIM_TTL recovery + account-scoped claim. |
Drain/publish loop (from ScheduledPostWorker) |
📦 Extract logic | same | New ScheduledPostPublisher in commons/jvmAndroid; call quartz publishAndConfirmDetailed (drop waitForOk) |
INostrClient.publishAndConfirmDetailed() |
✅ Reuse | quartz/.../accessories/NostrClientPublishExt.kt |
Key-free publish+confirm (commonMain) |
ScheduleAtButton / ScheduleAtPicker |
📦 Extract UI + presets | amethyst/.../creators/scheduling/ |
Presets/rounding → commons; picker to commons Compose |
roundUpToNextQuarterHour, preset fns |
📦 Extract (PURE) | ScheduleAtPicker.kt |
Move to commons util |
DraftWrapEvent (NIP-37, kind 31234) |
✅ Reuse | quartz/.../nip37Drafts/ |
commonMain; usable from Desktop as-is |
ScheduledPostWorker (WorkManager) |
⚠️ Android-only | amethyst/... |
Keep Android; Desktop uses OS scheduler |
ScheduledPostNotifier |
⚠️ Android-only | amethyst/... |
expect/actual notifier; Desktop = tray/log |
ComposeNoteDialog (composer) |
🆕 Extend | desktopApp/.../ui/ComposeNoteDialog.kt |
Add clock icon + picker + schedule path |
DeckColumnType (nav) |
🆕 Extend | desktopApp/.../ui/deck/DeckColumnType.kt |
Add/rename destination for Drafts & Scheduled |
DesktopDraftStore (local, article-oriented) |
⚠️ Reconcile | desktopApp/.../service/drafts/ |
Reuse pattern; add short-note drafts + opt-in NIP-37 |
DesktopHighlightStore (JSON+Mutex+atomic) |
✅ Reuse pattern | desktopApp/.../service/highlights/ |
Template for DesktopScheduledPostStore wiring |
iAccount.nip65RelayList.outboxFlow |
✅ Reuse | desktopApp/.../model/DesktopIAccount.kt |
Write-relay re-resolution (in-app path) |
| OS scheduler (launchd/schtasks/systemd) | 🆕 New (PR2) | expect in commons/jvmAndroid, actual jvmMain (or desktopApp) |
NOT commonMain (no iOS stub); Desktop-only registration; macOS-first |
amy publish-scheduled subcommand |
🆕 New (PR2, optional) | cli/.../commands/ + Main.kt dispatch |
Verb group scheduled run|list|publish-now|cancel + --scheduled-file; optional CLI parity, not the production path |
Research Insights & Revised Plan (deepen-plan)
Revised staging (supersedes the single-PR phase list)
| PR | Scope | Risk | Delivers |
|---|---|---|---|
| PR1 | Phase 0 (extract + fix store bugs) + Phase 1 (composer schedule) + Phase 2 (in-app timer + launch catch-up) + Scheduled tab | ~zero HIGH | "Schedule a note; it publishes at its time" — including the laptop-closed-overnight-reopened-next-morning case, which covers the large majority of real usage. |
| PR2 | App-closed OS firing (thin trigger → headless publish mode), macOS-first behind the abstraction | isolates all HIGH risks | Fires even if the app is never reopened around the scheduled time. |
| PR3 (optional) | NIP-37 opt-in draft sync (synced badge, dTag dedup) | MED | Cross-device drafts. Orthogonal to scheduling. |
Rationale (simplicity review): every HIGH risk in this plan lives in the OS/amy layer. Shipping PR1 first gets ~90% of the value at ~10% of reviewer/maintainer cost — which matters doubly for a first upstream FOSS PR.
CRITICAL / HIGH findings to fix before/within Phase 0
- Cross-process store race (arch C1 / sec H3).
ScheduledPostStore.claimDuePosts()guards with an in-processMutexonly — zero cross-process mutual exclusion. Two JVMs (app timer + OS-fired publisher) can both claim the same PENDING rows → duplicate publish or lost status write. Resolution options, best-first:- Single-writer discipline (recommended for PR2): OS trigger launches the
desktop binary's headless publish mode; a lockfile/heartbeat ensures the GUI
timer and the headless drain never run concurrently. Removes the race by
construction (only one drainer alive at a time), same
~/.amethyst/dir. - Switch the store to SQLite-WAL (cross-process-store research): the repo
already runs SQLite cross-process-correctly (
SQLiteEventStore, WAL +busy_timeout,BundledSQLiteDriver, no JNI).claimDuePostsbecomes oneBEGIN IMMEDIATE; UPDATE … WHERE status='PENDING' AND publish_at<=?— true ACID, lease recovery is a one-lineWHERE. UsePRAGMA synchronous=NORMAL, NOT theOFFthatSQLiteEventStoreuses (a lost SENT row = double-publish). This is the robust option if concurrent drain must be allowed. - File lock (
FileChannel.lock()on a sibling.lock, held across claim+persist, both paths) — correct but fragile (per-JVM semantics, NFS, stale locks). Least preferred.
- Single-writer discipline (recommended for PR2): OS trigger launches the
desktop binary's headless publish mode; a lockfile/heartbeat ensures the GUI
timer and the headless drain never run concurrently. Removes the race by
construction (only one drainer alive at a time), same
- Stuck
PUBLISHING→ silent permanent drop (arch C2 / sec H4). There is no timeout auto-recovery today;releaseClaimonly fires when the account isn't loaded. A crash/sleep between claim and ack strands the row inPUBLISHINGforever (never re-claimed, never purged, never surfaced) — the worst outcome for a scheduling feature. Fix in Phase 0 (benefits Android too): on load/claim, revert anyPUBLISHINGrow withnow - lastAttemptAtSec > CLAIM_TTL(e.g. 10 min) toPENDING. Add a test. This makes Open Question #3's assumed recovery real. - Account-scoped claim (amy-expert). Add
claimDuePosts(nowSec, accountPubkey)(or filter in the publisher). Otherwise the single multi-account file lets the headless/amy path publish account B's pre-signed event using account A's outbox.
KMP placement — corrected (kotlin-multiplatform skill, verified against verifyKmpPurity)
Jackson and java.io.File are forbidden in commonMain by the live purity gate
(commons/build.gradle.kts:223-232). Jackson reaches commons only transitively via
quartz's jvmAndroid api. Corrected layout:
commons/src/commonMain/.../scheduledposts/
ScheduledPost.kt (model + enum + file DTO) — PURE, commonMain OK
ScheduleTimePresets.kt (roundUpToNextQuarterHour, presets) — PURE (use TimeUtils.now, NOT System.currentTimeMillis)
commons/src/jvmAndroid/.../scheduledposts/
ScheduledPostStore.kt (Jackson + File + Mutex + StateFlow) — inject the File, NO expect/actual
ScheduledPostPublisher.kt (drain → INostrClient.publishAndConfirmDetailed)
ScheduledPostNotifier.kt (expect class — declared in jvmAndroid so iOS needs no stub)
OsScheduler.kt (expect class — declared in jvmAndroid)
commons/src/androidMain/.../scheduledposts/ ScheduledPostNotifier.kt (WorkManager), OsScheduler.kt
commons/src/jvmMain/.../scheduledposts/ ScheduledPostNotifier.kt (tray/log), OsScheduler.kt (launchd/schtasks/systemd)
- Store + publisher live in
jvmAndroid(both JVM Desktop & Android share it verbatim). Do not abstract the file path behind expect/actual — inject the resolvedFile(over-abstraction). NoiosMainfiles needed for any scheduling artifact. - Sequence the Android refactor (arch M4): 0a pure move + re-point Android (tests
green) → 0b swap
waitForOk→publishAndConfirmDetailed(re-verify "any relay acked = SENT") → 0c add claim-staleness sweep + test. Keep commits bisectable. - Kill the
waitForOkextraction (arch H3): both platforms call the quartzpublishAndConfirmDetailedprimitive; don't port Android'spendingPublishRelaysForpolling into commons.
OsScheduler placement (arch H2)
OsScheduler writes plist/unit/task files and shells out — it must NOT sit in
commons/jvmMain if that would put OS-orchestration on amy's CLI-safe classpath. Per
the KMP finding, declare the expect in jvmAndroid (actual in jvmMain for
Desktop OS-switch, androidMain = WorkManager). The Desktop-only registration logic
itself may equally live in desktopApp/jvmMain if it consumes only Desktop types —
decide by whether Android reuses the abstraction (it has WorkManager, so a desktopApp
home is also defensible). Either way: not commonMain, no iOS stub.
Relay re-resolution — fix the story (arch H1)
A fresh ~/.amy/ has no kind:10002 for the account, so the amy path's
ctx.outboxRelays() re-resolution never fires for the app-closed case — it always
falls back to the stored snapshot. Better: the app refreshes the stored relayUrls
snapshot whenever its outbox changes (cheap, app-side, always current), so whatever
the headless/amy path reads is fresh. With the thin-trigger design (headless mode of
the desktop binary) this is moot — it reads the app's own live NIP-65 state.
Compose picker (research) — no custom picker needed
Material3 DatePicker/TimePicker work on Compose Desktop (CMP 1.11.0, material3
1.9.0). Adapt Android's ScheduleAtPicker inside a Dialog (or DialogWindow for a
roomier modal) — not AlertDialog (project memory: subscriptions/state issues in
AlertDialog on Desktop). Reuse rememberDatePickerState/rememberTimePickerState,
keep the timezone + rounding logic verbatim. Split the matrix row: pure time utils =
clean 📦 extract; picker composable = adapt-to-Desktop (verify parity), closer to 🆕.
Security hardening (security review) — fold into acceptance criteria
- Store file + any scheduler files:
0600; refuse to write if the parent dir is group/world-writable. (DesktopDraftStoresets 0600 — copy it;DesktopHighlightStoresets none — do NOT copy that.) - OS trigger command: absolute, canonicalized path inside the app bundle, never a
bare name /
PATHlookup (else a poisoned~/.local/bin/amy= durable user-level RCE on a 5-min timer). Verify the path is non-writable by others before emitting it. - Generate plist/unit/task via strict escaping / argv arrays, never
sh -cstring concat; reject control chars / newlines in any interpolated path (injection). Reuse the repo's existing argv-array subprocess pattern (security/secret-tool/gsettings). - Key-free path must read zero secrets: amy's
Contexteagerly builds a signer from the keychain today — the publish path must use a key-freeContext/Identityvariant that never callskeyPair().privKey/secrets.resolve. Add a criterion: "publish path performs zero keychain reads." - Both drain paths re-verify the signature of the deserialized
signedEventJsonand assertevent.pubKey == accountPubkeybefore broadcast (tamper defense). - NIP-37 sync: keep default OFF; document that kind 31234 leaks that a draft of kind N exists at time T for pubkey P (content stays NIP-44 encrypted to self); 90-day NIP-40 expiry is advisory only.
Technical Approach
Architecture
Compose (Desktop, ComposeNoteDialog) commons (shared) OS
ClockButton → ScheduleAtPicker ─┐
pre-sign via account.signer ├─► ScheduledPostStore ───┐ register/cancel
(block if bunker offline) │ (commonMain: JSON, ├─► OsScheduler (expect/actual)
store.add(signedEventJson) ─┘ Mutex, claimDuePosts)│ launchd / schtasks / systemd
▲ │ │
In-app timer (app open) ────────────────────┘ │ ▼ every ~5 min WHEN queue>0
ScheduledPostPublisher.drain() │ amy publish-scheduled (cli, key-free)
re-resolve outbox relays │ drain due → publishAndConfirmDetailed
"Drafts & Scheduled" screen ────────────────────┘ → markSent / markFailed
Drafts tab (local + NIP-37, synced badge)
Scheduled tab (status + edit/cancel/publish-now)
Store path (shared by Desktop app AND amy):
~/.amethyst/scheduled/scheduled.json (per-account keyed by accountPubkey field;
one file, filtered by account — mirrors Android's single-file store).
Data-dir mismatch (must reconcile):
amyresolves its own data dir as~/.amy/<account>/…with its ownSecretStore, independent of the desktop app's~/.amethyst/. So the OS job must pointamyat the shared scheduled file explicitly — either a new--scheduled-file PATHflag onpublish-scheduled, or a convention both agree on.amydoes not need the desktop's keys (posts are pre-signed), but it does need read/write access to the shared scheduled store. Decide the mechanism in Phase 3.
Implementation Phases
Phase 0 — Extract shared scheduling core to commons/
- Move
ScheduledPost.kt(model + enum +ScheduledPostFile) tocommons/commonMain/.../scheduledposts/. PURE — no changes. - Move
ScheduledPostStore.ktto commons. Replacejava.io.Fileconstructor arg with a KMP-friendly path/IO abstraction:expecta storage-path/file-writer,actualforjvmAndroid(both JVM & Android are JVM → likely onejvmAndroidactual usingjava.io.File, plus iOS stub if needed). Keep injectednowSec: () -> Long.
- Extract the drain/publish/
waitForOkloop fromScheduledPostWorkerinto a pureScheduledPostPublisher(commons):Use quartz// commons/commonMain/.../scheduledposts/ScheduledPostPublisher.kt class ScheduledPostPublisher( private val store: ScheduledPostStore, private val client: INostrClient, private val resolveRelays: (post: ScheduledPost) -> Set<NormalizedRelayUrl>, ) { suspend fun drainDue(nowSec: Long): DrainReport { /* claim → publishAndConfirmDetailed → markSent/markFailed */ } }INostrClient.publishAndConfirmDetailed()instead of Android's hand-rolledpendingPublishRelaysForpoll. - Extract
roundUpToNextQuarterHour+ preset generators into a commons util. - Refactor Android
ScheduledPostWorker/ViewModel to call the commons store + publisher (no behavior change; Android tests stay green). expect/actualnotifier interface:ScheduledPostNotifier(Android = current impl; Desktop = tray notification or log).
Success: Android build + existing Android scheduling tests green against the
extracted commons code. :commons:jvmTest + :commons:compileKotlinJvm pass.
Phase 1 — Desktop store + composer scheduling
DesktopScheduledPostStorewiring: instantiate the commonsScheduledPostStorewith the Desktop path (~/.amethyst/scheduled/scheduled.json), provide as a CompositionLocal / app-level singleton inMain.kt(mirrorDesktopDraftStore).- Add clock icon to
ComposeNoteDialogtoolbar row (nearMediaAttachmentRow);MaterialSymbols.Schedule(already referenced? verify; regenerate subset font if it's a new codepoint — see CLAUDE.md Icons rule). - Add
scheduledForSec: Long?state +ScheduleAtPicker(extracted) in the dialog. - On Publish when
scheduledForSec != null:- Build the template, re-stamp
createdAt = scheduledForSec(feed ordering),account.signer.sign(template). - Bunker offline → block: if signer is remote (NIP-46) and signing fails/times out, show an inline message ("Signer must be online to schedule") and abort — do NOT store. (see brainstorm: Resolved / bunker offline)
store.add(ScheduledPost(... signedEventJson, relayUrls = outbox snapshot ...)).
- Build the template, re-stamp
- No draft deletion coupling yet (Desktop composer draft autosave handled in Phase 4).
Success: Scheduling a note writes a PENDING row; app compiles
(:desktopApp:compileKotlin), spotless clean.
Phase 2 — In-app timer publisher
- App-level coroutine (in
Main.ktscope) runsScheduledPostPublisher.drainDue()on a ticker (e.g. every 30–60s) while the app is open. resolveRelaysfor the in-app path = re-resolveiAccount.nip65RelayList.outboxFlow.valuefor the post's account, falling back to the storedrelayUrlssnapshot if empty.- On launch, run one catch-up drain → overdue posts auto-publish. (see brainstorm §4)
Success: A post scheduled for ~1 min out publishes while the app is open; overdue post publishes on relaunch.
Phase 3 — OS-level scheduler + amy publish-scheduled
amy publish-scheduledsubcommand (cli/.../commands/, wired intoMain.ktwhen(head)dispatch): resolves the shared store path,drainDue(now), publishes viapublishAndConfirmDetailed, marks status, exits 0/1. Honors--json.- Key-free: reads
signedEventJson— no signer needed. UsesContext.publish()→publishAndConfirmDetailed(accepts pre-signed events). - Relay resolution for the amy path: prefer amy's own
ctx.outboxRelays()(reads kind:10002 from its store, or a fresh network fetch) to honor "re-resolve at publish"; fall back to the storedrelayUrlssnapshot if amy has no synced kind:10002 for that account. (see Risks — divergence is now bounded, not total.)
- Key-free: reads
expect/actual OsScheduler(commonsjvmMainor desktopApp):fun ensureRegistered()/fun unregister()— idempotent.- macOS: write a
launchd~/Library/LaunchAgents/com.vitorpamplona.amethyst.scheduledposts.plistwithStartInterval~300s calling the bundledamy publish-scheduled;launchctl load/unload. - Windows:
schtasks /create /sc minute /mo 5 …invokingamy.bat publish-scheduled;/deleteon unregister. - Linux:
systemd --usertimer (.timer+.service) or crontab fallback.
- Lifecycle management: observe
store.flow; when pending count goes 0→>0 callensureRegistered(), when it drains to 0 callunregister(). (see brainstorm §1 user refinement) - Bundle
amywith the desktop distribution so the OS job has a stable path.amyis a Gradleapplication(launcheramy/amy.bat, mainClass…cli.MainKt) with its own jlink+jpackage bundle. Options (decide in impl): (a) add:clito the desktop jpackage image and resolve the launcher path at runtime, or (b) ship amy's jlink image inside the desktop app resources. Path must survive app updates.
Success: With the app closed, a due post publishes via the OS job on macOS (dogfood platform); registration appears/disappears with queue transitions.
Phase 4 — "Drafts & Scheduled" unified screen + draft sync
- Nav: extend
DeckColumnType— renameDraftsdestination presentation to "Drafts & Scheduled" (keepDraftsobject; addScheduledPostsOR make the existing Drafts screen a two-tab host). One sidebar entry, two tabs (Drafts | Scheduled). (see brainstorm §7) - Scheduled tab: list from
store.flow.listFor(account); each row = content preview + scheduled time + status chip (PENDING/PUBLISHING/SENT/FAILED). Actions:- Publish now →
store.publishNow(id)(+ trigger drain). - Cancel →
store.cancel(id). - Edit → cancel + reopen content in
ComposeNoteDialogas a draft to re-schedule. (see brainstorm §8) - FAILED rows show
lastError+ attemptCount and a retry (= publishNow).
- Publish now →
- Drafts tab: reconcile with existing
DesktopDraftStore. Add composer "Save as draft" for short notes; opt-in NIP-37 sync toggle publishes the draft asDraftWrapEvent(kind 31234). One list; synced rows get a small cloud badge; samedTagdedups local+synced. (see brainstorm §5 / Resolved)
Success: Screen lists both; all actions work; synced badge shows for NIP-37 drafts.
Phase 5 — Retry, cleanup, notifications, tests, docs
- Retry/backoff policy (reuse Android
attemptCount); retention purge (SENT >7d, CANCELLED >30d, FAILED kept) already in the store — verify on Desktop. - Desktop notifier
actual: system tray / OS notification on SENT/FAILED (optional; can stub+log for v1). - Tests: commons unit tests for store state machine,
claimDuePostsatomicity, publisher drain, preset/rounding utils. Manual testing sheet. - Docs: update
desktopAppplans; note the OS-job files created per platform.
Alternative Approaches Considered
- One OS job per post (brainstorm Approach B): precise but create/cancel churn and orphan risk. Rejected for lifecycle-managed single job.
- In-app-only + catch-up (brainstorm Approach A-lite): simplest but never fires when the app is closed. Rejected — user explicitly wants app-closed firing.
- Sign at publish time: would force the OS job / amy to hold keys. Rejected — breaks the key-free guarantee. (see brainstorm §3)
System-Wide Impact
Interaction Graph
ComposeNoteDialog.Publish(scheduled) → signer.sign → store.add → store.flow
emits → lifecycle observer → OsScheduler.ensureRegistered(). At fire time: OS job
→ amy publish-scheduled → store.claimDuePosts (flips PENDING→PUBLISHING) →
publishAndConfirmDetailed → markSent/markFailed → store.flow emits → if queue
now empty → OsScheduler.unregister(). Parallel in-app timer path claims the same
rows via the same atomic claimDuePosts, so only one path publishes each post.
Error & Failure Propagation
- Sign failure at schedule (bunker offline) → surfaced inline, nothing stored.
- Publish failure at fire time →
markFailed(id, error), row stays FAILED withlastError; retryable from the Scheduled tab. - Two-process write race (app + amy) → store must be safe across processes, not just coroutines (see Risks).
State Lifecycle Risks
- A post claimed as PUBLISHING by amy, then amy crashes →
releaseClaim/timeout must return it to PENDING so it isn't stuck. Verify the store's claim has a recovery path. - OS job registered but queue emptied by the in-app path → observer must still
unregister()(don't leak launchd/schtasks/systemd entries).
API Surface Parity
- Android composer path and Desktop composer path must produce identical
ScheduledPostrows (same re-stamp + relay snapshot semantics). amy publish-scheduledand the in-app timer must shareScheduledPostPublisher.
Integration Test Scenarios
- Schedule → close app → OS job fires → note appears at scheduled
created_at. - Schedule → keep app open → in-app timer fires before OS job; amy later finds nothing due (claim already consumed).
- Queue 0→1→0 registers then unregisters the OS job (inspect launchd/schtasks/systemd).
- Overdue on relaunch auto-publishes.
- Bunker offline at schedule → blocked, no row written.
- Change write relays after scheduling → in-app path uses new outbox; amy path uses stored snapshot (documented divergence).
Acceptance Criteria
Functional
- Clock icon in Desktop composer opens
ScheduleAtPickerwith presets. - Scheduling pre-signs and stores a
PENDINGrow; blocks if bunker offline. - In-app timer publishes due posts while app is open; catch-up on launch auto-publishes overdue posts.
- OS job registered on queue 0→1, cancelled on →0 (macOS/Windows/Linux).
amy publish-scheduleddrains + publishes pre-signed posts key-free (text +--json, exit 0/1/2).- "Drafts & Scheduled" sidebar screen with two tabs; Scheduled rows show status + edit(cancel+recompose)/cancel/publish-now.
- Drafts tab shows local + opt-in NIP-37 synced drafts with a synced badge.
Non-Functional
- Publisher never accesses signing keys; publish path performs zero keychain reads.
- Store is safe against concurrent drains — no duplicate publish, no lost row (test: concurrent double-drain → single publish).
- Stuck
PUBLISHINGrows auto-recover afterCLAIM_TTL(test: crash-mid-publish → row returns to PENDING). claimDuePostsis account-scoped; a row publishes only under its ownaccountPubkey.- Both drain paths re-verify signature +
event.pubKey == accountPubkeybefore broadcast. - Store file + scheduler files are
0600; write refused if parent dir is group/world-writable. - OS trigger uses an absolute, canonical, non-other-writable path (no
PATHlookup); scheduler files built via argv-array/strict escaping, neversh -c. - No orphaned OS-scheduler entries after queue drains / logout; registration reconciled idempotently on startup (level-triggered, not only Flow-edge-triggered).
- Future-
created_atrelay rejection handled as FAILED with a clear error; stale-overdue (> 24h) auto-publish is bounded, not silent.
Quality Gates
:commons:jvmTest,:desktopApp:compileKotlin, Android build green../gradlew spotlessApplyclean.- Manual testing sheet executed (Desktop macOS at minimum).
Success Metrics
- A note scheduled with the app closed publishes within one OS-tick (~5 min) of its time. Zero duplicate publishes across in-app + OS paths. Zero leaked OS jobs.
Dependencies & Risks
- Two-process store safety (HIGH): Android's
ScheduledPostStoreuses an in-processMutex+ atomic file rename. With amy and the app both writing, need cross-process safety (file lock, or amy-only-writes-when-app-absent, or a lock file). Must resolve in Phase 3. - amy bundling/path (HIGH): OS job needs a stable amy executable path surviving updates. Ties into desktop jpackage config.
- Data-dir reconciliation (HIGH): amy's
~/.amy/<account>/≠ desktop's~/.amethyst/. OS job must point amy at the shared scheduled file (new flag or convention). See Architecture note. - Relay re-resolution divergence (MED): in-app path re-resolves via
nip65RelayList.outboxFlow; amy path re-resolves viactx.outboxRelays()(needs synced kind:10002) else falls back to snapshot. Bounded divergence, acceptable v1. - Compose Multiplatform Material3 pickers (MED):
DatePicker/TimePickeron Desktop — verify parity or build a custom picker. - OS integration fragility (MED): launchd/schtasks/systemd differences; macOS is the dogfood target, others need testing.
- Icon subset font (LOW): new
MaterialSymbols.Schedulecodepoint requires regenerating the subset font (CLAUDE.md rule).
Open Questions (resolve during implementation)
- PR2 mechanism (decide before PR2): thin-trigger launching a headless mode of the
desktop binary (recommended — single-writer, same dir) vs separate bundled
amyvs SQLite-WAL store enabling safe concurrent drain. Deepen-plan recommends thin-trigger; confirm before building PR2. - Store engine: keep JSON (single-writer discipline) or migrate to SQLite-WAL (true cross-process ACID, repo already uses it)? Ties to Q1.
— RESOLVED: no recovery exists today; Phase 0 adds aclaimDuePostsstuck-PUBLISHING recoveryCLAIM_TTLstaleness sweep + test.- Screen shape (simplicity): a standalone
Scheduleddestination beside the existingDraftsone is cleaner for PR1 than refactoring the working Drafts screen into a two-tab host. Revisit the two-tab decision — brainstorm said one screen/two tabs, but a sibling destination may ship faster. Confirm with user. - Desktop notifier: system tray vs log-only for v1 (log-only acceptable).
- Time zone / DST — keep all storage/firing in epoch seconds (already the case); confine TZ logic to the picker's presentation layer.
- Filename: unify on Android's existing
scheduled_posts.json(notscheduled.json).
Sources & References
Origin
- Brainstorm: docs/brainstorms/2026-07-09-feat-desktop-note-scheduling-brainstorm.md — carried forward: OS-level lifecycle-managed publishing, pre-sign/key-free publisher, local+opt-in-NIP-37 drafts, one-screen-two-tabs, cancel+recompose edit.
Internal References
- Desktop composer:
desktopApp/.../ui/ComposeNoteDialog.kt(publishNote, ~636–673) - Desktop nav:
desktopApp/.../ui/deck/DeckColumnType.kt,DeckSidebar.kt(NAV_ITEMS) - Desktop publish + write relays:
desktopApp/.../network/RelayConnectionManager.kt,desktopApp/.../model/DesktopIAccount.kt(nip65RelayList.outboxFlow) - Desktop store pattern:
desktopApp/.../service/drafts/DesktopDraftStore.kt,.../highlights/DesktopHighlightStore.kt - Android scheduling:
amethyst/.../service/scheduledposts/{ScheduledPost,ScheduledPostStore,ScheduledPostWorker,ScheduledPostNotifier}.kt - Android composer scheduling block:
amethyst/.../home/ShortNotePostViewModel.ktsendPostSync()(~812–906) - Scheduling UI:
amethyst/.../creators/scheduling/{ScheduleAtButton,ScheduleAtPicker}.kt - Publish primitive:
quartz/.../nip01Core/relay/client/accessories/NostrClientPublishExt.kt(publishAndConfirmDetailed) - NIP-37:
quartz/.../nip37Drafts/DraftWrapEvent.kt(kind 31234) - amy dispatch:
cli/src/main/kotlin/.../cli/Main.kt(dispatchwhen(head)),cli/build.gradle.kts(application + jpackage)