From 25cafc4186fbb61114875b2a56b95baa6f2ba8c9 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:08:16 +0800 Subject: [PATCH 01/33] docs(schedule): plan bundled plugin --- .../08-24-schedule-calendar-view/check.jsonl | 4 + .../08-24-schedule-calendar-view/design.md | 15 ++ .../implement.jsonl | 5 + .../08-24-schedule-calendar-view/implement.md | 10 ++ .../tasks/08-24-schedule-calendar-view/prd.md | 35 ++++ .../08-24-schedule-calendar-view/task.json | 26 +++ .../tasks/08-24-schedule-plugin/check.jsonl | 7 + .../tasks/08-24-schedule-plugin/design.md | 164 ++++++++++++++++++ .../08-24-schedule-plugin/implement.jsonl | 8 + .../tasks/08-24-schedule-plugin/implement.md | 41 +++++ .trellis/tasks/08-24-schedule-plugin/prd.md | 63 +++++++ .../research/plugin-patterns.md | 45 +++++ .../tasks/08-24-schedule-plugin/task.json | 29 ++++ .../08-24-schedule-reminders/check.jsonl | 5 + .../tasks/08-24-schedule-reminders/design.md | 10 ++ .../08-24-schedule-reminders/implement.jsonl | 6 + .../08-24-schedule-reminders/implement.md | 11 ++ .../tasks/08-24-schedule-reminders/prd.md | 41 +++++ .../tasks/08-24-schedule-reminders/task.json | 26 +++ README.md | 1 + 20 files changed, 552 insertions(+) create mode 100644 .trellis/tasks/08-24-schedule-calendar-view/check.jsonl create mode 100644 .trellis/tasks/08-24-schedule-calendar-view/design.md create mode 100644 .trellis/tasks/08-24-schedule-calendar-view/implement.jsonl create mode 100644 .trellis/tasks/08-24-schedule-calendar-view/implement.md create mode 100644 .trellis/tasks/08-24-schedule-calendar-view/prd.md create mode 100644 .trellis/tasks/08-24-schedule-calendar-view/task.json create mode 100644 .trellis/tasks/08-24-schedule-plugin/check.jsonl create mode 100644 .trellis/tasks/08-24-schedule-plugin/design.md create mode 100644 .trellis/tasks/08-24-schedule-plugin/implement.jsonl create mode 100644 .trellis/tasks/08-24-schedule-plugin/implement.md create mode 100644 .trellis/tasks/08-24-schedule-plugin/prd.md create mode 100644 .trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md create mode 100644 .trellis/tasks/08-24-schedule-plugin/task.json create mode 100644 .trellis/tasks/08-24-schedule-reminders/check.jsonl create mode 100644 .trellis/tasks/08-24-schedule-reminders/design.md create mode 100644 .trellis/tasks/08-24-schedule-reminders/implement.jsonl create mode 100644 .trellis/tasks/08-24-schedule-reminders/implement.md create mode 100644 .trellis/tasks/08-24-schedule-reminders/prd.md create mode 100644 .trellis/tasks/08-24-schedule-reminders/task.json diff --git a/.trellis/tasks/08-24-schedule-calendar-view/check.jsonl b/.trellis/tasks/08-24-schedule-calendar-view/check.jsonl new file mode 100644 index 0000000..f64ac51 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-calendar-view/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Web correctness, security, and regression audit."} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Host lifecycle and interaction audit."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify the calendar is a projection, not a second model."} +{"file":".trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Reference patterns for independent check."} diff --git a/.trellis/tasks/08-24-schedule-calendar-view/design.md b/.trellis/tasks/08-24-schedule-calendar-view/design.md new file mode 100644 index 0000000..bacc93d --- /dev/null +++ b/.trellis/tasks/08-24-schedule-calendar-view/design.md @@ -0,0 +1,15 @@ +# Schedule calendar views design + +The plugin exports `activate({ api, refresh, root })`. Activation adds one +plugin stylesheet and returns a contribution with `load`, `faces`, +`renderFace`, `handleAction`, and `unmount`. + +`load` calculates a bounded current window and requests the canonical item +range once. Month/week/day faces filter and group that normalized item array; +they never write a second model. Controls carry action and item id. The handler +resolves the current item from loaded data so every mutation sends its latest +`version` as `expectedVersion`, then lets the host refresh the book. + +The module uses host escaping helpers for render values and `textContent` for +inline errors. It injects a `` for `/plugins/schedule/styles.css` and +removes only that owned element on unmount. diff --git a/.trellis/tasks/08-24-schedule-calendar-view/implement.jsonl b/.trellis/tasks/08-24-schedule-calendar-view/implement.jsonl new file mode 100644 index 0000000..8cbd8a5 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-calendar-view/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/frontend/index.md","reason":"Frontend implementation checklist."} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Native Web shell, escaping, and event delegation."} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Web quality and test requirements."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"One API/item source across three views."} +{"file":".trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Reference Web contribution and ready-gating patterns."} diff --git a/.trellis/tasks/08-24-schedule-calendar-view/implement.md b/.trellis/tasks/08-24-schedule-calendar-view/implement.md new file mode 100644 index 0000000..a696141 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-calendar-view/implement.md @@ -0,0 +1,10 @@ +# Schedule calendar views implementation plan + +1. Web agent reads the frozen parent/child contracts and owns only + `plugins/schedule/web/**` plus `tests/schedule-web.test.ts`. +2. Implement one load source and pure range/group/render helpers for month, + week, and day faces; add create and explicit transition actions. +3. Test grouping, escaping, canonical request bodies, expectedVersion, + ignore/no-write semantics, stylesheet lifecycle, and unavailable cleanup. +4. Main agent registers the static asset root after the Web agent finishes and + runs Web host plus full integration validation. diff --git a/.trellis/tasks/08-24-schedule-calendar-view/prd.md b/.trellis/tasks/08-24-schedule-calendar-view/prd.md new file mode 100644 index 0000000..c6f8a3a --- /dev/null +++ b/.trellis/tasks/08-24-schedule-calendar-view/prd.md @@ -0,0 +1,35 @@ +# Schedule calendar views (#32) + +## Goal + +Deliver ready-gated Schedule Web pages that present the Issue #31 +`schedule_items` data in month, week, and day views and expose only explicit +user actions, satisfying GitHub #32 without a second calendar model. + +## Requirements + +- Load through the existing bundled Web host and canonical Schedule API; + disabled/degraded plugins must add no faces or navigation. +- Month, week, and day views derive from the same range-list response, preserve + timezone-aware display, and distinguish scheduled/awaiting/active/done/ + cancelled without persisting the derived awaiting state. +- Provide create plus explicit confirm-start, snooze, complete, and cancel. +- Escape every dynamic string/attribute, remain compatible with delegated book + events, and clean plugin-owned CSS on unmount. +- Do not add recurrence, external sync, AI scheduling, record linking, or a + `calendar_events` data source. + +## Acceptance Criteria + +- [ ] Month, week, and day faces render the same fixture items in their correct + range/day positions and use item-provided timezone semantics. +- [ ] Create/confirm/snooze/done/cancel call only canonical routes and include + the item's current `expectedVersion`. +- [ ] Notification ignore has no Web-side write; awaiting is derived. +- [ ] Dynamic text is escaped and stylesheet activation/unmount is tested. +- [ ] Ready gating is covered by the existing host suite plus Schedule-specific + contribution tests; no module loads for disabled/degraded. + +## Dependency + +This child consumes, but does not redefine, the parent item/route contract. diff --git a/.trellis/tasks/08-24-schedule-calendar-view/task.json b/.trellis/tasks/08-24-schedule-calendar-view/task.json new file mode 100644 index 0000000..6880820 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-calendar-view/task.json @@ -0,0 +1,26 @@ +{ + "id": "schedule-calendar-view", + "name": "schedule-calendar-view", + "title": "Schedule calendar views (#32)", + "description": "", + "status": "planning", + "dev_type": null, + "scope": "frontend", + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/schedule-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-24-schedule-plugin", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-24-schedule-plugin/check.jsonl b/.trellis/tasks/08-24-schedule-plugin/check.jsonl new file mode 100644 index 0000000..d5ede95 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-plugin/check.jsonl @@ -0,0 +1,7 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Backend full-scope correctness and test review."} +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Audit atomic transitions, migrations, and timezone storage."} +{"file":".trellis/spec/backend/cli-agent-contract.md","reason":"Audit raw JSON, stderr, exit codes, and help."} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Frontend regression and interaction review."} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Audit Web host integration, escaping, and event delegation."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Audit contract consistency across all layers."} +{"file":".trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Verify behavior against researched plugin and notification contracts."} diff --git a/.trellis/tasks/08-24-schedule-plugin/design.md b/.trellis/tasks/08-24-schedule-plugin/design.md new file mode 100644 index 0000000..2e44b3f --- /dev/null +++ b/.trellis/tasks/08-24-schedule-plugin/design.md @@ -0,0 +1,164 @@ +# Schedule bundled plugin design + +## Architecture and ownership + +```text +schedule_items (plugin-owned PostgreSQL table) + -> ScheduleStore atomic operations + -> canonical /api/plugins/schedule/* routes + -> el schedule HTTP client + -> ready-gated Web contribution (month/week/day) + +schedule_items.next_reminder_at + -> non-overlapping PluginJob poll + -> schedule_reminder_deliveries unique claim + -> PluginContext.service("notifications.send") + -> ledger sent/failed result; schedule item remains scheduled +``` + +The Schedule package is the only owner of its persistence and domain rules. +Core only supplies the plugin lifecycle, plugin database URL, and named +notification capability. No code path reaches Core record or Inspiration +tables/APIs. + +## Cross-layer item contract + +An item serializes as camelCase JSON: + +```ts +type ScheduleStatus = "scheduled" | "active" | "done" | "cancelled"; + +interface ScheduleItem { + id: string; + title: string; + description: string | null; + scheduledStartAt: string; + scheduledEndAt: string | null; + timezone: string; + priority: number; + status: ScheduleStatus; + nextReminderAt: string | null; + confirmedStartAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + version: number; + createdAt: string; + updatedAt: string; + awaitingConfirmation: boolean; +} +``` + +All input instants require `Z` or an explicit numeric offset; bare local +datetimes are rejected. The separate IANA `timezone` preserves display intent +across DST while PostgreSQL stores instants as `TIMESTAMPTZ`. + +## Persistence + +`schedule_items` stores the contract fields except the derived boolean. Database +checks enforce status vocabulary, non-empty titles, version >= 1, priority +bounds, and end-after-start. Indexes cover status/reminder polling and calendar +range queries. + +`schedule_reminder_deliveries` stores `dedupe_key` (unique), item id, +`reminder_at`, `attempted_at`, terminal `sent | failed`, channel result JSON, +and a bounded failure string. The key is a stable item id plus the exact +`nextReminderAt` instant. Editing a title does not redeliver; explicit snooze +creates a new instant and therefore a new key. + +The job atomically claims a due reminder with `INSERT ... ON CONFLICT DO +NOTHING` before calling the external service. This is an intentional at-most-once +attempt policy: daemon restart or duplicate polling cannot repeat an already +claimed reminder. A crash between claim and send may lose one reminder; retrying +would instead permit duplicate notifications after a crash following send, +which the MVP rejects. Normal delivery failure is recorded and is not retried +until the user explicitly snoozes. + +## State machine and concurrency + +Every mutation includes `expectedVersion`; the store performs one conditional +UPDATE with `WHERE id = ? AND version = ? AND status IN (...)`, increments +version, and returns the row. Empty return distinguishes not-found from version +or state conflict through a follow-up read used only for error metadata, never +for deciding the write. + +- `confirm-start`: `scheduled -> active`; set `confirmedStartAt = now` and + `nextReminderAt = null`. +- `snooze`: `scheduled -> scheduled`; update only `nextReminderAt` and normal + bookkeeping (`version`, `updatedAt`). +- `complete`: `scheduled | active -> done`; set `completedAt = now`, clear the + reminder. +- `cancel`: `scheduled | active -> cancelled`; set `cancelledAt = now`, clear + the reminder. +- edit: only `scheduled`; editable planned fields follow the parent contract. + +The API returns 404 for absent ids, 409 with `currentVersion` and +`currentStatus` for stale/invalid transitions, and 400 for boundary validation. + +## HTTP API + +- `GET /api/plugins/schedule/items?from=&to=&status=` +- `POST /api/plugins/schedule/items` +- `GET /api/plugins/schedule/items/:id` +- `PATCH /api/plugins/schedule/items/:id` +- `POST /api/plugins/schedule/items/:id/confirm-start` +- `POST /api/plugins/schedule/items/:id/snooze` +- `POST /api/plugins/schedule/items/:id/complete` +- `POST /api/plugins/schedule/items/:id/cancel` +- `GET /api/plugins/schedule/reminders` + +List range semantics are `[from, to)`, selecting items whose planned interval +overlaps the range; a missing end is treated as a point at the start. + +## Notification dependency + +The package-local type exactly consumes the separately implemented contract: + +```ts +type NotificationSend = ( + request: { title: string; message: string }, + signal?: AbortSignal +) => Promise<{ + channels: Record<"mac" | "ntfy", + | { status: "sent" } + | { status: "disabled" } + | { status: "failed"; error: string }>; +}>; +``` + +Schedule requests `context.service("notifications.send")` +and declares `notifications:send`. A missing service fails registration and the +existing Host marks only Schedule degraded. No notification configuration, +credentials, action callbacks, or Core notifier imports cross this boundary. + +## CLI and Web + +`el schedule` exposes list/show/add/edit/confirm/snooze/done/cancel. Each action +uses the shared HTTP client, preserves raw JSON under `--json`, sets non-zero +exit on errors, and documents explicit-offset time formats and expectedVersion. + +The Web contribution is dynamically imported only for a ready plugin. It owns +four book faces (overview/create, month, week, day), queries range data, escapes +all dynamic content, and offers explicit confirm/snooze/done/cancel controls. +It does not fabricate notification buttons. + +## Parallel file ownership + +- Backend agent: `plugins/schedule/echolog.plugin.json`, `config.schema.json`, + package/tsconfig/tsup config, `plugins/schedule/src/**`, + `tests/schedule.test.ts`, and `tests/schedule.integration.ts`. +- CLI agent: the Schedule section of `src/cli/index.ts` and + `tests/schedule-cli.test.ts` only. +- Web agent: `plugins/schedule/web/**` and `tests/schedule-web.test.ts` only. +- Main agent: registry/build/config/lock integration, README/docs/GitHub/Trellis, + notification contract cross-check, conflict resolution, and full validation. +- Check agent: read-only first-pass review of the integrated diff. + +No two implementation agents may edit the same file. + +## Rollout and rollback + +The package is additive and uses plugin-owned migrations. It is default-enabled +to expose the feature; without the independent notification service Host change +it predictably degrades and remains isolated. Integration with that service +makes it ready without changing the package contract. Rollback disables the +plugin in config or removes the bundled registry entry; private tables remain. diff --git a/.trellis/tasks/08-24-schedule-plugin/implement.jsonl b/.trellis/tasks/08-24-schedule-plugin/implement.jsonl new file mode 100644 index 0000000..89fa7de --- /dev/null +++ b/.trellis/tasks/08-24-schedule-plugin/implement.jsonl @@ -0,0 +1,8 @@ +{"file":".trellis/spec/backend/index.md","reason":"Backend entry checklist for a new bundled plugin."} +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Plugin migrations, TIMESTAMPTZ, transactions, and atomic state updates."} +{"file":".trellis/spec/backend/error-handling.md","reason":"Canonical API error envelopes and conflict semantics."} +{"file":".trellis/spec/backend/cli-agent-contract.md","reason":"HTTP thin-client, --json, help, and exit-code requirements."} +{"file":".trellis/spec/frontend/index.md","reason":"Frontend entry checklist for the ready-gated Web contribution."} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Native Web shell, escaping, and delegated interaction conventions."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"One item contract across DB/API/CLI/Web/calendar views."} +{"file":".trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Reference plugin patterns and exact notification dependency contract."} diff --git a/.trellis/tasks/08-24-schedule-plugin/implement.md b/.trellis/tasks/08-24-schedule-plugin/implement.md new file mode 100644 index 0000000..3a5cbf2 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-plugin/implement.md @@ -0,0 +1,41 @@ +# Schedule bundled plugin implementation plan + +1. Tracking and planning gate + - Claim and comment on GitHub #31/#32; link branch and Trellis parent/children. + - Add README roadmap tracking and set task branch/base/scope metadata. + - Curate implement/check context and validate all three tasks. + - Activate both independently verifiable children before parallel dispatch. + +2. Parallel implementation (SOL High) + - Backend agent implements the owned package/server/store/job/test files. + - CLI agent implements only `el schedule` plus isolated HTTP CLI tests. + - Web agent implements only plugin Web assets and module tests. + - Every prompt starts with the active task path and requires reading jsonl, + PRD, design, implement, and relevant specs before edits. + +3. Main-agent integration + - Add workspace dependency/build ordering, bundled registry/Web asset entry, + example config, lockfile, README, and `docs/PLUGIN_API.md` schedule section. + - Verify the package-local notification interface exactly matches the sibling + task; do not merge that branch or alter shared Host. + +4. Focused verification + - Run Schedule unit, CLI, Web, plugin-host, and integration tests. + - Exercise manifest validation, canonical routes, raw JSON/error propagation, + atomic conflicts, reminder dedupe/restart/re-poll, failed channels, + disabled/degraded isolation, job timeout/non-entry, and Web ready gating. + +5. Independent check and finish + - Dispatch a separate SOL High check agent after implementation completes. + - Verify every finding against code and tests; fix valid findings. + - Run `pnpm test`, `pnpm typecheck`, and `pnpm build`. + - Load `trellis-update-spec`; update specs only for durable conventions. + - Recheck branch/status/diff, commit milestones, update GitHub issues, archive + child then parent tasks, and record the session. + +## Rollback points + +- Before registry/build integration, the new package is inert. +- Setting `plugins.schedule.enabled: false` prevents migration, registration, + jobs, and Web loading. +- No rollback step drops plugin tables or edits Core record data. diff --git a/.trellis/tasks/08-24-schedule-plugin/prd.md b/.trellis/tasks/08-24-schedule-plugin/prd.md new file mode 100644 index 0000000..b5e8f55 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-plugin/prd.md @@ -0,0 +1,63 @@ +# Schedule bundled plugin + +## Goal + +Ship one first-party `plugins/schedule` package that lets users plan time, +receive non-starting reminders, explicitly control execution state, and inspect +the same schedule data in month, week, and day views. This parent task owns the +cross-child contract and final integration for GitHub Issues #31 and #32. + +## Requirements + +- The parent delivery MUST comprise two independently verifiable children: + `schedule-reminders` for Issue #31 and `schedule-calendar-view` for Issue #32. +- Schedule MUST remain independent of Inspiration and Core records. It MUST NOT + import, create, start, update, or otherwise depend on either domain. +- Reaching `scheduledStartAt` MUST only request a notification. It MUST NOT + change item state or imply that work began. +- Only explicit `confirm-start` may move an item from `scheduled` to `active`; + `confirmedStartAt` MUST be the confirmation time, not the planned time. +- Ignoring a notification MUST change nothing. Snooze MUST only move + `nextReminderAt`. Completion and cancellation MUST be explicit. +- Persisted statuses are exactly `scheduled | active | done | cancelled`. + `awaitingConfirmation` is derived as `status === "scheduled" && + scheduledStartAt <= now` and MUST NOT be stored. +- One plugin-private `schedule_items` source MUST back CRUD/state transitions + and all month/week/day views. A `calendar_events` shadow model is forbidden. +- The package owns manifest/config, schema/migrations, store, routes, jobs, CLI, + Web contribution, docs, and tests. Canonical HTTP routes use + `/api/plugins/schedule/*`; CLI and Web are HTTP clients only. +- Notification delivery MUST use the named Host service + `PluginContext.service("notifications.send")` and manifest permission + `notifications:send`. This branch MUST define only the narrow local consumer + type and MUST NOT copy Core notifier or modify shared SDK/Host service files. +- A unique reminder ledger dedupe key, explicit `TIMESTAMPTZ` values plus an + IANA timezone, and optimistic `expectedVersion` atomic updates are mandatory. +- Disabled/degraded isolation, job timeout/non-reentry, restart/re-poll + deduplication, confirm races, snooze, and notification failure require tests. +- The MVP MUST NOT claim macOS notification action callbacks. Confirm, snooze, + complete, and cancel happen only through Web or CLI. +- Recurrence, external calendar sync, AI scheduling, Inspiration conversion, + and Core record linkage are out of scope. + +## Acceptance Criteria + +- [ ] Both child acceptance suites pass and use one `plugins/schedule` package. +- [ ] README, GitHub #31/#32, and this task tree point to the same branch, + package, semantics, and verification state. +- [ ] Web loads Schedule only while the bundled plugin is enabled and `ready`. +- [ ] Missing `notifications.send` degrades only Schedule; it does not prevent + Core or another plugin from starting. +- [ ] `pnpm test`, `pnpm typecheck`, and `pnpm build` pass from the repository + root after integration. +- [ ] An independent check agent reviews the integrated diff after all three + implementation agents finish, and verified findings are resolved. +- [ ] Changes are committed on `codex/schedule-plugin` without merging any + sibling branch. + +## Child Map + +- `08-24-schedule-reminders` — Issue #31: model, migration, store, reminder + ledger/job, state API, and `el schedule`. +- `08-24-schedule-calendar-view` — Issue #32: ready-gated month/week/day Web + views and explicit state actions over the Issue #31 API. diff --git a/.trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md b/.trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md new file mode 100644 index 0000000..f9011f2 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md @@ -0,0 +1,45 @@ +# Schedule reference research + +## Sources read + +- `AGENTS.md`, `.trellis/workflow.md`, backend/frontend specs, and cross-layer + guides. +- `docs/PLUGIN_API.md` and `packages/plugin-sdk` manifest/context contracts. +- tmux-status manifest/config/schema/migrations/store/routes/index/CLI package + layout and its contract/migration/adapter test organization. +- screen-time manifest/config/schema/store/index/routes/Web contribution, + plugin-host Web gating, lifecycle/job behavior, and unit/integration tests. +- GitHub #31/#32 and README plugin/roadmap sections. +- The sibling notification-service Trellis PRD/design/plan, read-only, for the + exact pending `notifications.send` signature and permission name. + +## Confirmed repository patterns + +- Bundled plugins are explicit workspace imports; no runtime filesystem loading. +- Plugin database access uses a private postgres/drizzle store and immutable + plugin migrations tracked by Host. +- Disabled plugins do not migrate or start. One degraded plugin does not block + later plugins. Canonical routes are guarded by Host readiness. +- Host jobs are interval-driven, non-overlapping, abort-aware, and release their + running marker after a rejecting timeout race. +- CLI is a shared HTTP thin client; `--json` returns raw bodies and errors exit + non-zero on stderr. Web contributions load only while enabled and ready. +- All database instants use `TIMESTAMPTZ`; state transitions use conditional + atomic UPDATE rather than read-decide-write. + +## Notification contract dependency + +The independently planned service is exactly `notifications.send`, gated by +manifest permission `notifications:send`, accepting `{title,message}` plus an +optional signal and returning independent `mac`/`ntfy` results with +`sent | disabled | failed`. Schedule locally mirrors only that consumer type. + +## Product decisions supplied by the user + +- Reminder arrival never starts work or creates a Core record. +- Confirming is explicit and timestamps the confirmation moment. +- Ignoring does nothing; snooze only moves the next reminder; done/cancel are + explicit; no fake macOS action callback. +- Calendar views project the schedule table instead of creating another model. +- Recurrence, calendar sync, AI scheduling, Inspiration conversion, and Core + record linkage are excluded. diff --git a/.trellis/tasks/08-24-schedule-plugin/task.json b/.trellis/tasks/08-24-schedule-plugin/task.json new file mode 100644 index 0000000..2b65beb --- /dev/null +++ b/.trellis/tasks/08-24-schedule-plugin/task.json @@ -0,0 +1,29 @@ +{ + "id": "schedule-plugin", + "name": "schedule-plugin", + "title": "Schedule bundled plugin", + "description": "", + "status": "planning", + "dev_type": null, + "scope": "cross-layer", + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/schedule-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "08-24-schedule-calendar-view", + "08-24-schedule-reminders" + ], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-24-schedule-reminders/check.jsonl b/.trellis/tasks/08-24-schedule-reminders/check.jsonl new file mode 100644 index 0000000..01cfda3 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-reminders/check.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Backend and test quality audit."} +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Concurrency, dedupe, migration, and time audit."} +{"file":".trellis/spec/backend/error-handling.md","reason":"HTTP status/body audit."} +{"file":".trellis/spec/backend/cli-agent-contract.md","reason":"CLI JSON/error/help audit."} +{"file":".trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Contract reference for independent check."} diff --git a/.trellis/tasks/08-24-schedule-reminders/design.md b/.trellis/tasks/08-24-schedule-reminders/design.md new file mode 100644 index 0000000..13056cc --- /dev/null +++ b/.trellis/tasks/08-24-schedule-reminders/design.md @@ -0,0 +1,10 @@ +# Schedule data and reminders design + +The authoritative architecture, item schema, routes, notification signature, +state machine, ledger policy, and file ownership are in the parent +`../08-24-schedule-plugin/design.md`. This child owns backend and CLI execution +of that frozen contract. It may not change shared SDK/Host files. + +Backend validation happens at routes; the store owns atomic persistence and +conflict classification. The reminder service owns ledger claim/send/finalize. +CLI performs only argument formatting and HTTP transport/display. diff --git a/.trellis/tasks/08-24-schedule-reminders/implement.jsonl b/.trellis/tasks/08-24-schedule-reminders/implement.jsonl new file mode 100644 index 0000000..dfcb857 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-reminders/implement.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/backend/index.md","reason":"Backend implementation checklist."} +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Private schema/migrations and atomic transitions."} +{"file":".trellis/spec/backend/error-handling.md","reason":"Route validation and structured conflicts."} +{"file":".trellis/spec/backend/cli-agent-contract.md","reason":"Schedule CLI transport/output contract."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Keep DB/API/CLI item shapes aligned."} +{"file":".trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Reference package/job/test and notification patterns."} diff --git a/.trellis/tasks/08-24-schedule-reminders/implement.md b/.trellis/tasks/08-24-schedule-reminders/implement.md new file mode 100644 index 0000000..9eae72e --- /dev/null +++ b/.trellis/tasks/08-24-schedule-reminders/implement.md @@ -0,0 +1,11 @@ +# Schedule data and reminders implementation plan + +1. Backend agent creates the Schedule package metadata, schema, migrations, + types, validators, store, reminder service, routes, plugin definition, and + backend/integration tests in its exclusive files. +2. CLI agent adds the `el schedule` command tree and isolated HTTP server tests + in its exclusive files, targeting the frozen parent routes. +3. Main agent wires workspace build/registry/config only after both agents + finish, then runs focused tests and resolves cross-layer issues. +4. Independent check validates concurrency, dedupe, restart, failure, disabled, + degraded, timeout, JSON, error, and no-Core-dependency criteria. diff --git a/.trellis/tasks/08-24-schedule-reminders/prd.md b/.trellis/tasks/08-24-schedule-reminders/prd.md new file mode 100644 index 0000000..d4c3f51 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-reminders/prd.md @@ -0,0 +1,41 @@ +# Schedule data and reminders (#31) + +## Goal + +Deliver the plugin-owned Schedule data model, explicit state machine, reminder +delivery ledger/job, canonical HTTP API, and `el schedule` client for GitHub #31. + +## Requirements + +- Implement the parent task's item contract, timezone rules, notification + dependency, atomic transitions, and non-goals without Core/Inspiration access. +- Creation initializes `status=scheduled`, `version=1`, and + `nextReminderAt=scheduledStartAt` unless an explicit reminder is given. +- Reminder polling must claim each item/reminder instant once, call + `notifications.send`, record the channel result, and never mutate item state. +- Confirm/snooze/complete/cancel and edit require `expectedVersion` and return + structured 409 conflict metadata on races or invalid states. +- The CLI must be a thin client with raw `--json`, non-zero error exits, exact + canonical paths, explicit-offset datetime help, and no local state inference. + +## Acceptance Criteria + +- [ ] Migrations create constrained/indexed `schedule_items` and a reminder + ledger with a unique dedupe key; every instant is `TIMESTAMPTZ`. +- [ ] CRUD/list/range and all state routes validate input and preserve the + parent JSON contract including derived `awaitingConfirmation`. +- [ ] Two concurrent confirms with the same expected version yield one active + item and one 409; `confirmedStartAt` reflects the winner's confirmation. +- [ ] Due polling, repeated polling, daemon/store restart, snooze, abort, and + notification failure have deterministic tests. +- [ ] Arrival/failed/ignored reminders do not start, complete, cancel, or create + any Core record. +- [ ] Disabled and missing-service/degraded cases remain isolated by Host tests. +- [ ] `el schedule` list/show/add/edit/confirm/snooze/done/cancel meets the CLI + agent contract in human and JSON modes. + +## Dependency + +This child establishes the HTTP/item contract consumed by the calendar child. +It depends at runtime on the separately delivered `notifications.send` Host +capability but tests it through a mock only. diff --git a/.trellis/tasks/08-24-schedule-reminders/task.json b/.trellis/tasks/08-24-schedule-reminders/task.json new file mode 100644 index 0000000..cdc6186 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-reminders/task.json @@ -0,0 +1,26 @@ +{ + "id": "schedule-reminders", + "name": "schedule-reminders", + "title": "Schedule data and reminders (#31)", + "description": "", + "status": "planning", + "dev_type": null, + "scope": "backend-cli", + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/schedule-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-24-schedule-plugin", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index c894ccc..d3573f5 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 m - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 +- **schedule(开发中)**:以同一套日程数据提供显式确认开始、延后提醒、完成/取消,以及月/周/日视图;到点只提醒,绝不自动启动或创建 Core record。实现追踪见 [Issue #31](https://github.com/CubePlus1/echolog/issues/31)、[Issue #32](https://github.com/CubePlus1/echolog/issues/32) 与 [Trellis 父任务](.trellis/tasks/08-24-schedule-plugin/)。 插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。 From 29fe6c387aa11a4fca00d0db7dd2e73d33093243 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:11:18 +0800 Subject: [PATCH 02/33] feat(plugins): add permission-gated notification service --- .trellis/spec/backend/index.md | 1 + .../spec/backend/plugin-api-guidelines.md | 54 ++++ README.md | 2 + docs/PLUGIN_API.md | 53 +++- .../plugin-sdk/echolog-plugin.schema.json | 4 +- packages/plugin-sdk/src/index.ts | 59 +++- src/core/notifier.ts | 221 +++++++++++-- src/core/plugins/create.ts | 7 +- src/core/plugins/host.ts | 19 +- tests/plugin-notification-host.test.ts | 232 ++++++++++++++ tests/plugin-notifier.test.ts | 295 ++++++++++++++++++ tests/plugin-sdk.test.ts | 54 ++++ 12 files changed, 963 insertions(+), 38 deletions(-) create mode 100644 .trellis/spec/backend/plugin-api-guidelines.md create mode 100644 tests/plugin-notification-host.test.ts create mode 100644 tests/plugin-notifier.test.ts diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index fdff96f..55ed471 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -20,6 +20,7 @@ This directory contains guidelines for backend development. Fill in each file wi | [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | Done | | [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill | | [CLI Agent Contract](./cli-agent-contract.md) | `el` CLI as the agent tool surface: --json, exit codes, help-as-spec | Done | +| [Bundled Plugin API Guidelines](./plugin-api-guidelines.md) | Named Core services, permissions, privacy, and compatibility | Done | --- diff --git a/.trellis/spec/backend/plugin-api-guidelines.md b/.trellis/spec/backend/plugin-api-guidelines.md new file mode 100644 index 0000000..5377216 --- /dev/null +++ b/.trellis/spec/backend/plugin-api-guidelines.md @@ -0,0 +1,54 @@ +# Bundled Plugin API Guidelines + +> How additive Core services cross the Bundled Plugin API v1 boundary. + +## Named Core services + +Plugin capabilities that need Core-owned behavior use an exact named service +through `PluginContext.service(...)`. Do not add a general event bus, expose the +Fastify instance, expose the Core Drizzle handle, or let a plugin import/write +Core table schemas. + +Every privileged service name MUST have one manifest permission and one Host +enforcement mapping. Keep these layers synchronized in the same change: + +1. SDK service request/result types and permission vocabulary; +2. `echolog-plugin.schema.json` permission enumeration; +3. `validatePluginManifest` runtime validation; +4. Host named-service permission mapping and Core injection; +5. `docs/PLUGIN_API.md` and contract tests. + +Authorization failures throw a structured `PluginError` with +`PLUGIN_DEPENDENCY_MISSING` and identify the requesting plugin. Check permission +before revealing whether a privileged service is installed. Disabled plugin +lifecycle hooks never run; a bad service request during startup degrades only +that plugin and initialization continues with later plugins. + +## Notification service pattern + +`notifications.send` requires `notifications:send`. The plugin receives only a +typed send function. Core retains global/channel enablement, ntfy server/topic, +credentials, delivery timeouts, and transport dependencies. + +Operational delivery outcomes are data, not swallowed exceptions: return both +`mac` and `ntfy` with `sent`, `disabled`, or `failed`. Failed results contain a +bounded, non-sensitive error and never include endpoint URLs, topics, response +bodies, or notification content. A channel failure must not erase the other +channel's outcome. + +Bound transport waits with a rejecting timeout race even when an underlying +operation ignores `AbortSignal`; also honor the caller signal and remove timers +and listeners after settlement. Existing Core fire-and-forget callers may keep +a `void` compatibility wrapper, but plugin-facing calls use the result-bearing +primitive so delivery failures remain observable. + +## Compatibility checklist + +- Treat v1 additions as additive: preserve existing generic service calls, + bundled manifests, scheduler call signatures, routes, and lifecycle order. +- Test permission denied and allowed paths, disabled hooks, degraded-plugin + isolation, per-channel outcomes, non-2xx responses, abort/timeout behavior, + and legacy caller compatibility. +- Run the SDK test/build before root tests when workspace packages have not yet + produced their `dist` type entrypoints; finish with root `test`, `typecheck`, + and `build`. diff --git a/README.md b/README.md index c894ccc..18785a9 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,8 @@ screen-understanding 已接通 Provider/Keychain 管理、macOS 原生截图助 EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 manifest 标识,独立注册路由、定时任务、迁移、配置校验、健康检查和 Web 资源;插件初始化、迁移或采集失败会将对应插件置为 degraded,不阻断 Core 启动或主动记录。插件 Web 模块只能通过宿主提供的同源 HTTP API 读写数据,不能直连数据库。 +Plugin API v1 的通知 named service 由 [GitHub Issue #35](https://github.com/CubePlus1/echolog/issues/35) 追踪,作为 [#31 日程插件](https://github.com/CubePlus1/echolog/issues/31)、[#33 灵感记录](https://github.com/CubePlus1/echolog/issues/33) 与 [#34 灵感推送](https://github.com/CubePlus1/echolog/issues/34) 的共享 Core 前置能力。 + - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 31d438a..b5fceb7 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -59,10 +59,12 @@ restrict Host APIs and make review scope explicit: | --- | --- | | `process:exec` | Bounded `execFile` command runner; no shell | | `database:plugin` | Database URL for plugin-owned tables | +| `notifications:send` | Core-owned `notifications.send` delivery service | A plugin without the corresponding declaration receives a structured `PLUGIN_DEPENDENCY_MISSING` error. Plugins MUST NOT import Core table schemas or -write Core records directly. +write Core records directly. Manifests that declare any permission outside this +fixed vocabulary are invalid. ## Lifecycle @@ -109,6 +111,53 @@ ignores `AbortSignal` cannot leave the job permanently marked as running. 64 KiB). It is written directly to child stdin and MUST NOT be copied into argv, environment variables, logs, or errors. Execution remains no-shell. +### Notification service + +A plugin that declares `notifications:send` obtains the exact named service +from its context: + +```ts +const sendNotification = context.service("notifications.send"); +const result = await sendNotification( + { + title: "Reminder", + message: "Stand-up starts in five minutes", + }, + signal +); +``` + +The request contains only `title` and `message`; the optional second argument +is an `AbortSignal`. The result reports the Core channels independently: + +```ts +{ + channels: { + mac: { status: "sent" }, + ntfy: { status: "failed", error: "Delivery failed" }, + }, +} +``` + +Each `mac` and `ntfy` result is exactly one of `sent`, `disabled`, or `failed`. +Only `failed` includes a bounded, non-sensitive `error` string. One channel's +failure does not erase the other channel's outcome. + +Notification configuration is a Core privacy boundary. Global and per-channel +enablement, ntfy server and topic, credentials, delivery timeouts, and +deployment details MUST NOT cross into plugin code. Plugins can observe only +the two channel outcomes above, never configuration or endpoint values. The +notification content itself is passed to the configured delivery channels and +MAY leave the local machine when ntfy is enabled, so a plugin MUST send only +content appropriate for that configured destination. + +The downstream schedule plugin tracked by GitHub Issue #31 declares +`notifications:send` and calls this service when a reminder becomes due. In the +inspiration recording/push flow tracked by Issues #33 and #34, recording and +storage remain plugin-owned and the push path calls this service only when a +stored inspiration is selected for delivery. Those plugins are downstream of +this API and are not implemented by the v1 service contract itself. + ## Routes and errors Canonical plugin routes use: @@ -141,7 +190,7 @@ Stable error codes: | `PLUGIN_DISABLED` | 503 | | `PLUGIN_DEGRADED` | 503 | | `PLUGIN_API_INCOMPATIBLE` | 503 | -| `PLUGIN_DEPENDENCY_MISSING` | 503 | +| `PLUGIN_DEPENDENCY_MISSING` | 403 | | `PLUGIN_EXEC_FAILED` | 502 | | `PLUGIN_TIMEOUT` | 504 | | `PLUGIN_OUTPUT_INVALID` | 502 | diff --git a/packages/plugin-sdk/echolog-plugin.schema.json b/packages/plugin-sdk/echolog-plugin.schema.json index a8b5865..dbab657 100644 --- a/packages/plugin-sdk/echolog-plugin.schema.json +++ b/packages/plugin-sdk/echolog-plugin.schema.json @@ -46,7 +46,9 @@ }, "permissions": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "enum": ["process:exec", "database:plugin", "notifications:send"] + }, "uniqueItems": true }, "requires": { diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index fd9103d..af81fd4 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,5 +1,13 @@ export const PLUGIN_API_VERSION = "1" as const; +export const SUPPORTED_PLUGIN_PERMISSIONS = [ + "process:exec", + "database:plugin", + "notifications:send", +] as const; + +export type PluginPermission = (typeof SUPPORTED_PLUGIN_PERMISSIONS)[number]; + export type PluginState = | "disabled" | "validating" @@ -31,7 +39,7 @@ export interface PluginManifest { web?: string; }; capabilities: string[]; - permissions: string[]; + permissions: PluginPermission[]; requires: { coreApi: string; platforms?: string[]; @@ -100,6 +108,33 @@ export interface PluginCommandResult { exitCode: number; } +export type PluginNotificationChannel = "mac" | "ntfy"; + +export interface PluginNotificationRequest { + title: string; + message: string; +} + +export type PluginNotificationChannelResult = + | { status: "sent" } + | { status: "disabled" } + | { status: "failed"; error: string }; + +export type PluginNotificationStatus = PluginNotificationChannelResult["status"]; + +export interface PluginNotificationResult { + channels: Record; +} + +export type PluginNotificationSend = ( + request: PluginNotificationRequest, + signal?: AbortSignal +) => Promise; + +export interface PluginCoreServices { + "notifications.send": PluginNotificationSend; +} + export interface PluginLogger { debug(fields: unknown, message?: string): void; info(fields: unknown, message?: string): void; @@ -122,6 +157,7 @@ export interface PluginContext { registerJob(job: PluginJob): void; registerReportSection(section: PluginReportSection): void; exec(request: PluginCommandRequest, signal?: AbortSignal): Promise; + service(name: "notifications.send"): PluginCoreServices["notifications.send"]; service(name: string): T; } @@ -150,7 +186,7 @@ export interface PluginRuntimeInfo { enabled: boolean; state: PluginState; capabilities: string[]; - permissions: string[]; + permissions: PluginPermission[]; webEntry?: string; error?: { code: PluginErrorCode; @@ -176,6 +212,9 @@ export class PluginError extends Error { const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const SUPPORTED_PLUGIN_PERMISSION_SET = new Set( + SUPPORTED_PLUGIN_PERMISSIONS +); export function validatePluginManifest(manifest: PluginManifest): string[] { const errors: string[] = []; @@ -196,8 +235,20 @@ export function validatePluginManifest(manifest: PluginManifest): string[] { ] as const) { if (!Array.isArray(values)) { errors.push(`${name} must be an array`); - } else if (new Set(values).size !== values.length) { - errors.push(`${name} must not contain duplicates`); + } else { + if (new Set(values).size !== values.length) { + errors.push(`${name} must not contain duplicates`); + } + if (name === "permissions") { + const unsupported = values.filter( + (permission) => !SUPPORTED_PLUGIN_PERMISSION_SET.has(permission) + ); + if (unsupported.length > 0) { + errors.push( + `permissions contains unsupported values: ${unsupported.join(", ")}` + ); + } + } } } return errors; diff --git a/src/core/notifier.ts b/src/core/notifier.ts index 795ec07..016ff78 100644 --- a/src/core/notifier.ts +++ b/src/core/notifier.ts @@ -1,38 +1,211 @@ import notifier from "node-notifier"; -import { loadConfig } from "./config.js"; +import type { + PluginNotificationChannelResult, + PluginNotificationRequest, + PluginNotificationResult, +} from "@echolog/plugin-sdk"; +import { loadConfig, type Config } from "./config.js"; -export function notifyMac(title: string, message: string) { - const config = loadConfig(); - if (!config.notifications.enabled || !config.notifications.mac) return; +const DELIVERY_TIMEOUT_MS = 5_000; +const MAX_ERROR_LENGTH = 160; - notifier.notify({ - title: `EchoLog: ${title}`, - message, - sound: "default", - timeout: 10, +export interface MacNotificationOptions { + title: string; + message: string; + sound: string; + timeout: number; +} + +export type MacNotify = ( + options: MacNotificationOptions, + callback: (error: Error | null) => void +) => void; + +export type NotificationFetch = ( + input: string | URL | Request, + init?: RequestInit +) => Promise>; + +export interface NotificationDependencies { + loadConfig?: () => Config; + macNotify?: MacNotify; + fetch?: NotificationFetch; + timeoutMs?: number; +} + +class DeliveryAbortedError extends Error {} +class DeliveryTimeoutError extends Error {} + +function failed(error: string): PluginNotificationChannelResult { + return { + status: "failed", + error: error.slice(0, MAX_ERROR_LENGTH), + }; +} + +function failureResult( + channel: "mac" | "ntfy", + error: unknown +): PluginNotificationChannelResult { + if (error instanceof DeliveryTimeoutError) { + return failed(`${channel} notification timed out`); + } + if (error instanceof DeliveryAbortedError) { + return failed(`${channel} notification aborted`); + } + return failed(`${channel} notification failed`); +} + +function runBounded( + operation: (signal: AbortSignal) => Promise, + callerSignal: AbortSignal | undefined, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const controller = new AbortController(); + let settled = false; + let timer: ReturnType | undefined; + + const cleanup = () => { + if (timer) clearTimeout(timer); + callerSignal?.removeEventListener("abort", onAbort); + }; + const finish = (result: { value: T } | { error: unknown }) => { + if (settled) return; + settled = true; + cleanup(); + if ("error" in result) reject(result.error); + else resolve(result.value); + }; + const onAbort = () => { + controller.abort(); + finish({ error: new DeliveryAbortedError() }); + }; + + if (callerSignal?.aborted) { + onAbort(); + return; + } + callerSignal?.addEventListener("abort", onAbort, { once: true }); + timer = setTimeout(() => { + controller.abort(); + finish({ error: new DeliveryTimeoutError() }); + }, timeoutMs); + + Promise.resolve() + .then(() => operation(controller.signal)) + .then( + (value) => finish({ value }), + (error: unknown) => finish({ error }) + ); }); } -export async function notifyNtfy(title: string, message: string) { - const config = loadConfig(); - if (!config.notifications.enabled || !config.notifications.ntfy.enabled) - return; +const defaultMacNotify: MacNotify = (options, callback) => { + notifier.notify(options, (error) => callback(error)); +}; - const { server, topic } = config.notifications.ntfy; - const url = `${server}/${topic}`; +async function sendMac( + request: PluginNotificationRequest, + signal: AbortSignal | undefined, + dependencies: Required< + Pick + > +): Promise { + try { + await runBounded( + () => + new Promise((resolve, reject) => { + dependencies.macNotify( + { + title: `EchoLog: ${request.title}`, + message: request.message, + sound: "default", + timeout: 10, + }, + (error) => (error ? reject(error) : resolve()) + ); + }), + signal, + dependencies.timeoutMs + ); + return { status: "sent" }; + } catch (error) { + return failureResult("mac", error); + } +} +async function sendNtfy( + request: PluginNotificationRequest, + config: Config, + signal: AbortSignal | undefined, + dependencies: Required< + Pick + > +): Promise { try { - await fetch(url, { - method: "POST", - headers: { Title: `EchoLog: ${title}` }, - body: message, - }); + const response = await runBounded( + (deliverySignal) => { + const { server, topic } = config.notifications.ntfy; + return dependencies.fetch(`${server}/${topic}`, { + method: "POST", + headers: { Title: `EchoLog: ${request.title}` }, + body: request.message, + signal: deliverySignal, + }); + }, + signal, + dependencies.timeoutMs + ); + if (!response.ok) { + return failed(`ntfy notification failed with HTTP ${response.status}`); + } + return { status: "sent" }; + } catch (error) { + return failureResult("ntfy", error); + } +} + +export async function sendNotification( + request: PluginNotificationRequest, + signal?: AbortSignal, + dependencies: NotificationDependencies = {} +): Promise { + let config: Config; + try { + config = (dependencies.loadConfig ?? loadConfig)(); } catch { - // ntfy unavailable, fail silently + const unavailable = failed("notification configuration unavailable"); + return { channels: { mac: unavailable, ntfy: unavailable } }; + } + + if (!config.notifications.enabled) { + return { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "disabled" }, + }, + }; } + + const timeoutMs = dependencies.timeoutMs ?? DELIVERY_TIMEOUT_MS; + const mac = config.notifications.mac + ? sendMac(request, signal, { + macNotify: dependencies.macNotify ?? defaultMacNotify, + timeoutMs, + }) + : Promise.resolve({ status: "disabled" }); + const ntfy = config.notifications.ntfy.enabled + ? sendNtfy(request, config, signal, { + fetch: dependencies.fetch ?? globalThis.fetch, + timeoutMs, + }) + : Promise.resolve({ status: "disabled" }); + const [macResult, ntfyResult] = await Promise.all([mac, ntfy]); + + return { channels: { mac: macResult, ntfy: ntfyResult } }; } -export function notify(title: string, message: string) { - notifyMac(title, message); - notifyNtfy(title, message).catch(() => {}); +export function notify(title: string, message: string): void { + void sendNotification({ title, message }).catch(() => {}); } diff --git a/src/core/plugins/create.ts b/src/core/plugins/create.ts index 7cf8eae..d348fbb 100644 --- a/src/core/plugins/create.ts +++ b/src/core/plugins/create.ts @@ -1,5 +1,6 @@ -import type { PluginLogger } from "@echolog/plugin-sdk"; +import type { PluginLogger, PluginNotificationSend } from "@echolog/plugin-sdk"; import { getDbUrl, type Config } from "../config.js"; +import { sendNotification } from "../notifier.js"; import { runPluginCommand } from "./command-runner.js"; import { PluginHost } from "./host.js"; import { runPluginMigrations } from "./migrations.js"; @@ -12,6 +13,9 @@ export function createPluginHost(config: Config, logger: PluginLogger): PluginHo "config.tracker is deprecated; migrate it to plugins.screen-time.config" ); } + const sendPluginNotification: PluginNotificationSend = (request, signal) => + sendNotification(request, signal, { loadConfig: () => config }); + return new PluginHost({ definitions: bundledPlugins, configuration: config.plugins, @@ -20,6 +24,7 @@ export function createPluginHost(config: Config, logger: PluginLogger): PluginHo commandRunner: runPluginCommand, services: { "database.url": getDbUrl(config), + "notifications.send": sendPluginNotification, }, }); } diff --git a/src/core/plugins/host.ts b/src/core/plugins/host.ts index 1b93e1c..e255746 100644 --- a/src/core/plugins/host.ts +++ b/src/core/plugins/host.ts @@ -8,6 +8,7 @@ import { type PluginDoctorCheck, type PluginJob, type PluginLogger, + type PluginPermission, type PluginReportSection, type PluginRoute, type PluginErrorCode, @@ -33,6 +34,11 @@ interface PluginJobRuntime { abortController: AbortController | null; } +const SERVICE_PERMISSIONS: Readonly> = { + "database.url": "database:plugin", + "notifications.send": "notifications:send", +}; + export interface PluginHostOptions { definitions: readonly PluginDefinition[]; configuration?: Record< @@ -156,21 +162,22 @@ export class PluginHost { return options.commandRunner(request, signal); }, service: (name: string): T => { - if (!Object.hasOwn(options.services ?? {}, name)) { - throw new Error(`Plugin service is not available: ${name}`); - } + const requiredPermission = SERVICE_PERMISSIONS[name]; if ( - name === "database.url" && - !definition.manifest.permissions.includes("database:plugin") + requiredPermission && + !definition.manifest.permissions.includes(requiredPermission) ) { throw new PluginError( "PLUGIN_DEPENDENCY_MISSING", - `Plugin ${id} has not declared database:plugin`, + `Plugin ${id} has not declared ${requiredPermission}`, id, info.state, 403 ); } + if (!Object.hasOwn(options.services ?? {}, name)) { + throw new Error(`Plugin service is not available: ${name}`); + } return options.services?.[name] as T; }, }; diff --git a/tests/plugin-notification-host.test.ts b/tests/plugin-notification-host.test.ts new file mode 100644 index 0000000..d221e6b --- /dev/null +++ b/tests/plugin-notification-host.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + PLUGIN_API_VERSION, + PluginError, + type PluginDefinition, + type PluginLogger, + type PluginManifest, +} from "@echolog/plugin-sdk"; +import { PluginHost } from "../src/core/plugins/host.js"; + +const logger: PluginLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +function manifest( + id: string, + permissions: PluginManifest["permissions"] = [] +): PluginManifest { + return { + manifestVersion: 1, + id, + version: "1.0.0", + apiVersion: PLUGIN_API_VERSION, + displayName: id, + description: `${id} notification test plugin`, + entries: { server: "./dist/server.js" }, + capabilities: [], + permissions, + requires: { coreApi: "^1.0.0" }, + }; +} + +function host( + definitions: PluginDefinition[], + services: Record = {} +): PluginHost { + return new PluginHost({ + definitions, + logger, + migrationRunner: async () => {}, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + services, + }); +} + +test("denies notifications.send without its declared permission", async () => { + let serviceCalls = 0; + let deniedError: unknown; + const pluginHost = host( + [{ + manifest: manifest("notification-denied"), + defaultEnabled: true, + async start(context) { + try { + const send = context.service<( + request: { title: string; message: string } + ) => Promise>("notifications.send"); + await send({ title: "private title", message: "private message" }); + } catch (error) { + deniedError = error; + throw error; + } + }, + }], + { + "notifications.send": async () => { + serviceCalls++; + }, + } + ); + + await pluginHost.initialize(); + + const [plugin] = pluginHost.list(); + assert.equal(plugin?.state, "degraded"); + assert.equal(plugin?.error?.code, "PLUGIN_DEPENDENCY_MISSING"); + assert.ok(deniedError instanceof PluginError); + assert.equal(deniedError.code, "PLUGIN_DEPENDENCY_MISSING"); + assert.equal(deniedError.statusCode, 403); + assert.equal(deniedError.pluginId, "notification-denied"); + assert.equal(serviceCalls, 0); +}); + +test("returns the Core-owned send function to a permitted plugin", async () => { + const requests: Array<{ title: string; message: string }> = []; + const expected = { + channels: { + mac: { status: "sent" as const }, + ntfy: { status: "disabled" as const }, + }, + }; + let received: unknown; + let receivedService: unknown; + const send = async (request: { title: string; message: string }) => { + requests.push(request); + return expected; + }; + const pluginHost = host( + [{ + manifest: manifest("notification-allowed", ["notifications:send"]), + defaultEnabled: true, + async start(context) { + const service = context.service("notifications.send"); + receivedService = service; + received = await service({ title: "Reminder", message: "Stand up" }); + }, + }], + { "notifications.send": send } + ); + + await pluginHost.initialize(); + + assert.equal(pluginHost.list()[0]?.state, "ready"); + assert.equal(receivedService, send); + assert.deepEqual(requests, [{ title: "Reminder", message: "Stand up" }]); + assert.equal(received, expected); +}); + +test("does not run notification lifecycle hooks for a disabled plugin", async () => { + let hooks = 0; + let serviceCalls = 0; + const pluginHost = host( + [{ + manifest: manifest("notification-disabled", ["notifications:send"]), + defaultEnabled: false, + register(context) { + hooks++; + context.service("notifications.send"); + }, + start(context) { + hooks++; + context.service("notifications.send"); + }, + }], + { + "notifications.send": async () => { + serviceCalls++; + }, + } + ); + + await pluginHost.initialize(); + + assert.equal(pluginHost.list()[0]?.state, "disabled"); + assert.equal(hooks, 0); + assert.equal(serviceCalls, 0); +}); + +test("isolates an unavailable notification service from later plugins", async () => { + let healthyStarted = false; + const pluginHost = host([ + { + manifest: manifest("notification-unavailable", ["notifications:send"]), + defaultEnabled: true, + start(context) { + context.service("notifications.send"); + }, + }, + { + manifest: manifest("notification-healthy"), + defaultEnabled: true, + start() { + healthyStarted = true; + }, + }, + ]); + + await pluginHost.initialize(); + + const states = Object.fromEntries( + pluginHost.list().map(({ id, state }) => [id, state]) + ); + assert.deepEqual(states, { + "notification-healthy": "ready", + "notification-unavailable": "degraded", + }); + assert.equal(healthyStarted, true); +}); + +test("one notification plugin failure does not block a healthy plugin", async () => { + let healthyResult: unknown; + const send = async (request: { title: string; message: string }) => { + if (request.title === "fail") throw new Error("delivery adapter unavailable"); + return { + channels: { + mac: { status: "sent" as const }, + ntfy: { status: "sent" as const }, + }, + }; + }; + const pluginHost = host( + [ + { + manifest: manifest("notification-broken", ["notifications:send"]), + defaultEnabled: true, + async start(context) { + await context.service("notifications.send")({ + title: "fail", + message: "first plugin", + }); + }, + }, + { + manifest: manifest("notification-working", ["notifications:send"]), + defaultEnabled: true, + async start(context) { + healthyResult = await context.service( + "notifications.send" + )({ title: "ok", message: "second plugin" }); + }, + }, + ], + { "notifications.send": send } + ); + + await pluginHost.initialize(); + + const states = Object.fromEntries( + pluginHost.list().map(({ id, state }) => [id, state]) + ); + assert.deepEqual(states, { + "notification-broken": "degraded", + "notification-working": "ready", + }); + assert.deepEqual(healthyResult, { + channels: { mac: { status: "sent" }, ntfy: { status: "sent" } }, + }); +}); diff --git a/tests/plugin-notifier.test.ts b/tests/plugin-notifier.test.ts new file mode 100644 index 0000000..0a420ba --- /dev/null +++ b/tests/plugin-notifier.test.ts @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { Config } from "../src/core/config.js"; +import { notify, sendNotification } from "../src/core/notifier.js"; + +function config( + notifications: Partial & { + ntfy?: Partial; + } = {} +): Config { + return { + server: { port: 19827, host: "127.0.0.1" }, + database: { + host: "127.0.0.1", + port: 5432, + name: "echolog", + user: "echolog", + password: "not-used", + }, + sync: { target: "", auto: false }, + notifications: { + enabled: notifications.enabled ?? true, + mac: notifications.mac ?? true, + ntfy: { + enabled: notifications.ntfy?.enabled ?? true, + server: notifications.ntfy?.server ?? "https://ntfy.invalid", + topic: notifications.ntfy?.topic ?? "private-topic", + }, + rules: notifications.rules ?? { + task_overtime_minutes: 60, + idle_reminder_enabled: false, + idle_check_start: "09:00", + idle_check_end: "18:00", + daily_report_time: "18:00", + end_of_day_time: "19:00", + }, + }, + }; +} + +const request = { title: "Reminder", message: "Private notification body" }; + +test("reports both channels disabled when notifications are globally disabled", async () => { + let macCalls = 0; + let fetchCalls = 0; + + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ enabled: false }), + macNotify: () => { + macCalls++; + }, + fetch: async () => { + fetchCalls++; + return new Response(null, { status: 200 }); + }, + }); + + assert.deepEqual(result, { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "disabled" }, + }, + }); + assert.equal(macCalls, 0); + assert.equal(fetchCalls, 0); +}); + +test("reports a disabled channel independently from a sent channel", async () => { + let fetchedUrl = ""; + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ mac: false }), + macNotify: () => assert.fail("disabled mac channel must not be called"), + fetch: async (input) => { + fetchedUrl = String(input); + return new Response(null, { status: 204 }); + }, + }); + + assert.deepEqual(result, { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "sent" }, + }, + }); + assert.equal(fetchedUrl, "https://ntfy.invalid/private-topic"); +}); + +test("reports mac callback success and failure", async (t) => { + await t.test("success", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: (_options, callback) => callback(null), + }); + + assert.deepEqual(result.channels.mac, { status: "sent" }); + assert.deepEqual(result.channels.ntfy, { status: "disabled" }); + }); + + await t.test("failure", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: (_options, callback) => + callback(new Error("mac notification unavailable")), + }); + + assert.equal(result.channels.mac.status, "failed"); + assert.ok( + result.channels.mac.status === "failed" && + result.channels.mac.error.length > 0 && + result.channels.mac.error.length <= 200 + ); + assert.deepEqual(result.channels.ntfy, { status: "disabled" }); + }); +}); + +test("bounds a non-cooperative mac delivery with an internal timeout", async () => { + const startedAt = Date.now(); + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: () => {}, + timeoutMs: 10, + }); + + assert.equal(result.channels.mac.status, "failed"); + assert.ok(Date.now() - startedAt < 1_000, "delivery timeout must be bounded"); + assert.match( + result.channels.mac.status === "failed" ? result.channels.mac.error : "", + /timed out/i + ); +}); + +test("honors a caller-provided abort signal for mac delivery", async () => { + const controller = new AbortController(); + const delivery = sendNotification(request, controller.signal, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: () => {}, + timeoutMs: 10_000, + }); + controller.abort(); + + const result = await delivery; + assert.equal(result.channels.mac.status, "failed"); + assert.match( + result.channels.mac.status === "failed" ? result.channels.mac.error : "", + /abort/i + ); +}); + +test("reports ntfy success, non-2xx, and network failures", async (t) => { + const ntfyOnly = () => config({ mac: false }); + + await t.test("success", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: ntfyOnly, + fetch: async () => new Response(null, { status: 201 }), + }); + assert.deepEqual(result.channels.ntfy, { status: "sent" }); + }); + + await t.test("non-2xx", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: ntfyOnly, + fetch: async () => + new Response("upstream-private-response", { status: 503 }), + }); + assert.equal(result.channels.ntfy.status, "failed"); + if (result.channels.ntfy.status === "failed") { + assert.match(result.channels.ntfy.error, /503/); + assert.equal(result.channels.ntfy.error.includes("private-topic"), false); + assert.equal( + result.channels.ntfy.error.includes("upstream-private-response"), + false + ); + assert.ok(result.channels.ntfy.error.length <= 200); + } + }); + + await t.test("network failure", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: ntfyOnly, + fetch: async () => { + throw new Error( + "network unavailable for https://ntfy.invalid/private-topic with Private notification body" + ); + }, + }); + assert.equal(result.channels.ntfy.status, "failed"); + if (result.channels.ntfy.status === "failed") { + assert.ok(result.channels.ntfy.error.length > 0); + assert.equal(result.channels.ntfy.error.includes("private-topic"), false); + assert.equal( + result.channels.ntfy.error.includes("Private notification body"), + false + ); + } + }); +}); + +test("aborts an in-flight ntfy transport when its delivery times out", async () => { + let transportSignal: AbortSignal | undefined; + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ mac: false }), + fetch: async (_input, init) => { + transportSignal = init?.signal ?? undefined; + return new Promise>(() => {}); + }, + timeoutMs: 10, + }); + + assert.equal(transportSignal?.aborted, true); + assert.equal(result.channels.ntfy.status, "failed"); + assert.match( + result.channels.ntfy.status === "failed" ? result.channels.ntfy.error : "", + /timed out/i + ); +}); + +test("keeps channel results independent when one delivery fails", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: () => config(), + macNotify: (_options, callback) => + callback(new Error("mac unavailable")), + fetch: async () => new Response(null, { status: 200 }), + }); + + assert.equal(result.channels.mac.status, "failed"); + assert.deepEqual(result.channels.ntfy, { status: "sent" }); +}); + +test("legacy notify remains a void, non-rejecting fire-and-forget wrapper", async () => { + const directory = mkdtempSync(join(tmpdir(), "echolog-notifier-test-")); + const configPath = join(directory, "config.yaml"); + const originalConfigPath = process.env.ECHOLOG_CONFIG_PATH; + const originalFetch = globalThis.fetch; + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + + writeFileSync( + configPath, + [ + "server:", + " port: 19827", + " host: 127.0.0.1", + "database:", + " host: 127.0.0.1", + " port: 5432", + " name: echolog", + " user: echolog", + " password: not-used", + "sync:", + " target: ''", + " auto: false", + "notifications:", + " enabled: true", + " mac: false", + " ntfy:", + " enabled: true", + " server: https://ntfy.invalid", + " topic: private-topic", + " rules:", + " task_overtime_minutes: 60", + " idle_reminder_enabled: false", + " idle_check_start: '09:00'", + " idle_check_end: '18:00'", + " daily_report_time: '18:00'", + " end_of_day_time: '19:00'", + "", + ].join("\n") + ); + + try { + process.env.ECHOLOG_CONFIG_PATH = configPath; + globalThis.fetch = async () => { + throw new Error("simulated background delivery rejection"); + }; + process.on("unhandledRejection", onUnhandled); + + const returnValue: void = notify("Legacy", "Scheduler-compatible"); + assert.equal(returnValue, undefined); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(unhandled, []); + } finally { + process.off("unhandledRejection", onUnhandled); + globalThis.fetch = originalFetch; + if (originalConfigPath === undefined) { + delete process.env.ECHOLOG_CONFIG_PATH; + } else { + process.env.ECHOLOG_CONFIG_PATH = originalConfigPath; + } + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/plugin-sdk.test.ts b/tests/plugin-sdk.test.ts index 8b74e91..6efab95 100644 --- a/tests/plugin-sdk.test.ts +++ b/tests/plugin-sdk.test.ts @@ -1,11 +1,31 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { PLUGIN_API_VERSION, + SUPPORTED_PLUGIN_PERMISSIONS, validatePluginManifest, type PluginManifest, + type PluginPermission, } from "@echolog/plugin-sdk"; +interface ManifestSchema { + properties: { + permissions: { + items: { + enum: string[]; + }; + }; + }; +} + +const manifestSchema = JSON.parse( + readFileSync( + new URL("../packages/plugin-sdk/echolog-plugin.schema.json", import.meta.url), + "utf8" + ) +) as ManifestSchema; + function manifest(overrides: Partial = {}): PluginManifest { return { manifestVersion: 1, @@ -26,6 +46,15 @@ test("accepts a valid bundled plugin manifest", () => { assert.deepEqual(validatePluginManifest(manifest()), []); }); +test("accepts every supported plugin permission", () => { + assert.deepEqual( + validatePluginManifest( + manifest({ permissions: [...SUPPORTED_PLUGIN_PERMISSIONS] }) + ), + [] + ); +}); + test("rejects unstable ids, incompatible API versions, and duplicates", () => { const errors = validatePluginManifest( manifest({ @@ -39,3 +68,28 @@ test("rejects unstable ids, incompatible API versions, and duplicates", () => { assert.ok(errors.some((error) => error.includes("apiVersion"))); assert.ok(errors.some((error) => error.includes("duplicates"))); }); + +test("runtime validation rejects unknown plugin permissions", () => { + const errors = validatePluginManifest( + manifest({ permissions: ["notifications:read" as PluginPermission] }) + ); + + assert.ok( + errors.some( + (error) => + error === "permissions contains unsupported values: notifications:read" + ) + ); +}); + +test("manifest schema enumerates the exact supported permission vocabulary", () => { + assert.deepEqual( + manifestSchema.properties.permissions.items.enum, + [...SUPPORTED_PLUGIN_PERMISSIONS] + ); + assert.ok( + !manifestSchema.properties.permissions.items.enum.includes( + "notifications:read" + ) + ); +}); From 3bd3f38deff966cb4ab5f65b2079b8477623130e Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:11:53 +0800 Subject: [PATCH 03/33] chore(trellis): archive plugin notification service --- .../check.jsonl | 6 + .../design.md | 111 ++++++++++++++++++ .../implement.jsonl | 6 + .../implement.md | 43 +++++++ .../08-24-plugin-notification-service/prd.md | 60 ++++++++++ ...urrent-notification-and-plugin-boundary.md | 37 ++++++ .../task.json | 29 +++++ 7 files changed, 292 insertions(+) create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-service/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-service/design.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-service/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-service/research/current-notification-and-plugin-boundary.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-service/task.json diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/check.jsonl new file mode 100644 index 0000000..fbb2187 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/check.jsonl @@ -0,0 +1,6 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/backend/index.md", "reason": "Backend full-scope quality checklist"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify structured errors and isolation"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify timeouts and project checks"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Verify synchronized cross-layer contracts"} +{"file": ".trellis/tasks/08-24-plugin-notification-service/research/current-notification-and-plugin-boundary.md", "reason": "Verify implementation against audited baseline"} diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/design.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/design.md new file mode 100644 index 0000000..7ecbb34 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/design.md @@ -0,0 +1,111 @@ +# Bundled Plugin API v1 notification service design + +## Boundary and contract + +The service is obtained as: + +```ts +const sendNotification = context.service("notifications.send"); +const result = await sendNotification( + { title: "Reminder", message: "Stand-up starts in five minutes" }, + signal +); +``` + +The SDK owns these public types: + +```ts +type PluginNotificationChannel = "mac" | "ntfy"; +type PluginNotificationStatus = "sent" | "disabled" | "failed"; + +interface PluginNotificationRequest { + title: string; + message: string; +} + +type PluginNotificationChannelResult = + | { status: "sent" } + | { status: "disabled" } + | { status: "failed"; error: string }; + +interface PluginNotificationResult { + channels: Record; +} + +type PluginNotificationSend = ( + request: PluginNotificationRequest, + signal?: AbortSignal +) => Promise; +``` + +The exact named service is `notifications.send`; schedule and inspiration +plugins MUST request this name and declare `notifications:send`. + +## Data flow + +```text +Plugin manifest notifications:send + -> PluginContext.service("notifications.send") permission check + -> Core-owned PluginNotificationSend adapter + -> sendNotification({title,message}, signal) + -> mac + ntfy bounded delivery in parallel + -> per-channel sent | disabled | failed result + -> plugin decides whether/how to react +``` + +The adapter closes over Core configuration. No config object, ntfy server/topic, +or credential-bearing value crosses the plugin boundary. + +## Notifier implementation + +`sendNotification` is the result-bearing Core primitive. It reads current Core +configuration once, evaluates global and per-channel enablement, and runs mac +and ntfy delivery independently. Each channel converts operational failure into +a structured `failed` result; no channel failure erases the other channel's +result. Error text is normalized and bounded without embedding endpoint URLs or +request bodies. + +mac delivery wraps `node-notifier`'s callback and has a host timeout/abort race. +ntfy uses `fetch` with an abortable composed timeout and treats non-2xx as +failure. The fixed delivery timeout is an internal Core policy, not plugin +configuration. + +The existing `notify(title, message): void` remains as a compatibility wrapper: +it starts `sendNotification` and intentionally consumes the returned promise. +The scheduler therefore remains fire-and-forget and cannot be stopped by a +delivery failure, while plugin callers use the explicit result-bearing service. + +## Permission and manifest enforcement + +The SDK defines the supported permission vocabulary: +`process:exec`, `database:plugin`, and `notifications:send`. Runtime manifest +validation rejects unknown values and duplicates; the JSON Schema uses the same +enumeration. + +The Host maps named services to required permissions. Access to +`notifications.send` without `notifications:send` throws `PluginError` with +code `PLUGIN_DEPENDENCY_MISSING`, status 403, and the requesting plugin's current +state. The same centralized mapping continues enforcing `database.url`. + +Disabled plugin hooks never execute. A plugin that requests an unauthorized or +unavailable service during registration/startup becomes degraded without +preventing subsequent plugins from becoming ready. Existing job timeout and +shutdown abort semantics remain unchanged; the notification send function also +honors the job's signal. + +## Compatibility and non-goals + +This is additive within `apiVersion: "1"`: existing manifests, Context calls, +HTTP routes, scheduler callers, and bundled plugins keep their current behavior. +No current bundled plugin needs the new permission. + +There is no event bus, dynamic plugin loading, Fastify exposure, Core database +handle exposure, Core-table write API, notification configuration read API, or +schedule/inspiration plugin implementation in this task. + +## Rollback + +The new SDK types, permission value, service injection, and notifier primitive +form one coherent extension. Rollback removes those additions while retaining +the legacy `notify` implementation/calls; no database migration or stored data +is involved. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.jsonl new file mode 100644 index 0000000..c99500c --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.jsonl @@ -0,0 +1,6 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/backend/index.md", "reason": "Backend module and quality entry point"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Structured plugin permission and delivery errors"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Timeout, background loop, and verification rules"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "SDK-manifest-Host-Core contract synchronization"} +{"file": ".trellis/tasks/08-24-plugin-notification-service/research/current-notification-and-plugin-boundary.md", "reason": "Audited notifier, Host, and lifecycle baseline"} diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.md new file mode 100644 index 0000000..6d8c8ce --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/implement.md @@ -0,0 +1,43 @@ +# Bundled Plugin API v1 notification service implementation plan + +1. Synchronize tracking and contracts + - Create/claim a GitHub enhancement issue linked to #31, #33, and #34. + - Record branch, base branch, and backend/cross-layer scope in Trellis. + - Add SDK notification request/result/send types and supported permission + vocabulary; update runtime validation, manifest JSON Schema, and docs. + +2. Implement Core delivery and Host injection + - Refactor `src/core/notifier.ts` around a bounded, abort-aware, + result-bearing `sendNotification` primitive with injectable seams for + deterministic tests. + - Preserve `notify(title, message): void` for the existing scheduler. + - Inject `notifications.send` from `createPluginHost` and enforce + `notifications:send` in `PluginHost.service`. + +3. Add automated coverage + - SDK/schema contract tests for supported and unknown permissions. + - Notifier tests for disabled, sent, failed, non-2xx, aggregate, abort, and + timeout results without contacting real notification services. + - Host tests for permission denial/allowance, unavailable service behavior, + disabled lifecycle isolation, and degraded-plugin continuation. + - Scheduler compatibility test or type/behavior assertion proving the void + wrapper remains non-rejecting. + +4. Parallel ownership after task activation + - SDK/protocol agent owns `packages/plugin-sdk/**`, `docs/PLUGIN_API.md`, and + its SDK/schema tests. + - Core agent owns `src/core/notifier.ts`, `src/core/plugins/{host,create}.ts`, + and Core-focused tests it creates. + - Test/review agent initially owns analysis and a separate regression test + file; it must not edit another agent's owned files during the first pass. + - Main agent integrates conflicts, updates README/Trellis/specs, and runs the + final full-scope review. + +5. Verification and finish + - Run focused tests during integration, then `pnpm test`, `pnpm typecheck`, + and `pnpm build`. + - Review backend and cross-layer quality checklists, update the backend spec + with the named-service/permission/result convention, inspect the full diff, + and commit coherent work on the task branch. + - Close the GitHub issue only after acceptance, archive the Trellis task, + update README tracking, and record the session per repository workflow. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/prd.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/prd.md new file mode 100644 index 0000000..65149f4 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/prd.md @@ -0,0 +1,60 @@ +# Bundled Plugin API v1 notification service + +## Goal + +Extend Bundled Plugin API v1 with a permission-gated Core notification +capability so first-party schedule and inspiration plugins can request delivery +without learning notification credentials or importing Core internals. + +## Requirements + +- The additive v1 contract MUST expose the exact named Core service + `notifications.send` through `PluginContext.service(...)`. +- A manifest MUST declare `notifications:send` before the Host returns that + service. Missing permission MUST raise the structured + `PLUGIN_DEPENDENCY_MISSING` error for the requesting plugin. +- The service request MUST contain only a notification `title` and `message`. + Notification enablement, ntfy server/topic, and all credentials or deployment + details remain Core-owned and MUST NOT be observable by plugins. +- The service response MUST report both `mac` and `ntfy` independently with one + of `sent`, `disabled`, or `failed`; a failed channel MUST include a bounded, + non-sensitive error message and MUST NOT be silently converted to success. +- Core notifier delivery MUST treat ntfy non-2xx responses as failures and MUST + bound/abort delivery waits. A caller-provided `AbortSignal` MUST be honored. +- Existing Core scheduler call sites and fire-and-forget behavior MUST remain + source-compatible: notification delivery failures MUST NOT reject or stop the + reminder loop. +- Disabled plugins MUST never receive or invoke services because their + lifecycle hooks do not run. Permission or startup failures MUST degrade only + the offending plugin and MUST NOT block later plugins or Core startup. +- The extension MUST NOT add a general event bus, expose Fastify or the Core + Drizzle handle, or permit plugins to write Core tables. +- SDK types, runtime manifest validation, JSON Schema, Host injection, API + documentation, README/Trellis/GitHub tracking, and automated tests MUST stay + synchronized. + +## Acceptance Criteria + +- [x] `PluginNotificationSend` accepts `{ title: string; message: string }` plus + an optional `AbortSignal` and resolves to `{ channels: { mac, ntfy } }`. +- [x] Each channel value has `{ status: "sent" | "disabled" | "failed" }` and + only failed values include an `error` string. +- [x] Manifest TypeScript validation and JSON Schema accept + `notifications:send`, reject unknown permissions, and continue accepting + the existing `process:exec` and `database:plugin` permissions. +- [x] A plugin without `notifications:send` gets a 403 + `PLUGIN_DEPENDENCY_MISSING`; a permitted plugin receives only the send + function, never notification configuration. +- [x] Tests cover global/channel disablement, mac success/failure, ntfy + success/non-2xx/network failure, result aggregation, abort/timeout + behavior, scheduler compatibility, disabled lifecycle isolation, and + degraded-plugin isolation. +- [x] `pnpm test`, `pnpm typecheck`, and `pnpm build` pass. +- [x] The task is committed on `codex/plugin-notification-service` and is not + merged into another branch. + +## Notes + +- Downstream consumers are GitHub Issues #31 (schedule plugin) and #33/#34 + (inspiration recording/push). This task establishes their shared Core service + contract but does not implement those plugins. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/research/current-notification-and-plugin-boundary.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/research/current-notification-and-plugin-boundary.md new file mode 100644 index 0000000..a8a780a --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/research/current-notification-and-plugin-boundary.md @@ -0,0 +1,37 @@ +# Current notification and plugin boundary audit + +## Existing behavior + +- `src/core/notifier.ts` exposes `notifyMac`, `notifyNtfy`, and a synchronous + `notify` wrapper. Disabled channels return `undefined`; mac callback errors are + not observed; ntfy catches network errors; the wrapper catches its promise. +- `src/core/scheduler.ts` invokes `notify(title, message)` without awaiting it. + This behavior must remain compatible. +- `PluginContext` exposes generic named `service(name)`. The Host currently + injects only `database.url` and gates it with `database:plugin`. +- `process:exec` is separately enforced by `context.exec`. +- The manifest JSON Schema currently allows any non-empty permission string, + while runtime validation checks only duplicate values. +- Plugin jobs have non-overlap, hard timeout races, AbortSignal cancellation, + and reverse-order shutdown. Disabled hooks do not run; startup failures + degrade one plugin and initialization continues. + +## Relevant files + +- `packages/plugin-sdk/src/index.ts` +- `packages/plugin-sdk/echolog-plugin.schema.json` +- `src/core/plugins/host.ts` +- `src/core/plugins/create.ts` +- `src/core/notifier.ts` +- `src/core/scheduler.ts` +- `tests/plugin-sdk.test.ts` +- `tests/plugin-host.test.ts` +- `docs/PLUGIN_API.md` +- `.trellis/spec/backend/{index,error-handling,quality-guidelines}.md` +- `.trellis/spec/guides/{index,cross-layer-thinking-guide,code-reuse-thinking-guide}.md` + +## Tracking relationship + +GitHub #31 (schedule plugin) and #33/#34 (inspiration recording/push) need a +shared notification delivery capability. This task provides only the reusable +Core boundary and explicitly leaves those plugin implementations downstream. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/task.json b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/task.json new file mode 100644 index 0000000..1c47292 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/task.json @@ -0,0 +1,29 @@ +{ + "id": "plugin-notification-service", + "name": "plugin-notification-service", + "title": "Bundled Plugin API v1 notification service", + "description": "", + "status": "completed", + "dev_type": null, + "scope": "backend,cross-layer", + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-24", + "completedAt": "2026-08-24", + "branch": "codex/plugin-notification-service", + "base_branch": "main", + "worktree_path": null, + "commit": "29fe6c3", + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "GitHub Issue #35; shared prerequisite for #31 and #33/#34.", + "meta": { + "issue": 35, + "issue_url": "https://github.com/CubePlus1/echolog/issues/35" + } +} From a59d62550202628bddbe7a8b56755d46beb08e22 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:12:10 +0800 Subject: [PATCH 04/33] chore(trellis): record plugin notification session --- .trellis/workspace/codex/index.md | 41 +++++++++++++++++++++++++++ .trellis/workspace/codex/journal-1.md | 41 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 .trellis/workspace/codex/index.md create mode 100644 .trellis/workspace/codex/journal-1.md diff --git a/.trellis/workspace/codex/index.md b/.trellis/workspace/codex/index.md new file mode 100644 index 0000000..45adb72 --- /dev/null +++ b/.trellis/workspace/codex/index.md @@ -0,0 +1,41 @@ +# Workspace Index - codex + +> Journal tracking for AI development sessions. + +--- + +## Current Status + + +- **Active File**: `journal-1.md` +- **Total Sessions**: 1 +- **Last Active**: 2026-08-24 + + +--- + +## Active Documents + + +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~41 | Active | + + +--- + +## Session History + + +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| +| 1 | 2026-08-24 | Bundled Plugin API v1 notification service | `29fe6c3`, `3bd3f38` | `codex/plugin-notification-service` | + + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/codex/journal-1.md b/.trellis/workspace/codex/journal-1.md new file mode 100644 index 0000000..3309d44 --- /dev/null +++ b/.trellis/workspace/codex/journal-1.md @@ -0,0 +1,41 @@ +# Journal - codex (Part 1) + +> AI development session journal +> Started: 2026-08-24 + +--- + + + +## Session 1: Bundled Plugin API v1 notification service + +**Date**: 2026-08-24 +**Task**: Bundled Plugin API v1 notification service +**Branch**: `codex/plugin-notification-service` + +### Summary + +Added notifications.send with notifications:send permission enforcement, structured mac/ntfy delivery results, timeout/privacy isolation, SDK/schema/docs/spec updates, and regression tests; pnpm test/typecheck/build passed. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `29fe6c3` | (see git log) | +| `3bd3f38` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 6dcfbc0a55afb929acd53f4d2127b241728c5271 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:12:27 +0800 Subject: [PATCH 05/33] docs(trellis): detail plugin notification session --- .trellis/workspace/codex/journal-1.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.trellis/workspace/codex/journal-1.md b/.trellis/workspace/codex/journal-1.md index 3309d44..f13b060 100644 --- a/.trellis/workspace/codex/journal-1.md +++ b/.trellis/workspace/codex/journal-1.md @@ -19,7 +19,11 @@ Added notifications.send with notifications:send permission enforcement, structu ### Main Changes -(Add details) +- Added the `notifications.send` named Core service and SDK request/result types. +- Enforced `notifications:send` across TypeScript validation, JSON Schema, and Host lookup. +- Refactored Core delivery to return independent mac/ntfy results with bounded timeout and abort behavior. +- Preserved the legacy scheduler-facing `notify(title, message): void` wrapper. +- Added Plugin API/README/backend-spec documentation and permission, delivery, compatibility, and isolation tests. ### Git Commits @@ -30,7 +34,10 @@ Added notifications.send with notifications:send permission enforcement, structu ### Testing -- [OK] (Add test results) +- [OK] `pnpm test` — 113 tests, 112 passed, 1 platform-conditional skip +- [OK] `pnpm typecheck` +- [OK] `pnpm build` +- [OK] `git diff --check` ### Status @@ -38,4 +45,4 @@ Added notifications.send with notifications:send permission enforcement, structu ### Next Steps -- None - task complete +- Schedule plugin #31 and inspiration plugins #33/#34 can adopt the new service contract independently. From 3ab8946c10b707c44b704295951b073921db8cea Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:37:34 +0800 Subject: [PATCH 06/33] feat(inspiration): add bundled capture and flow plugin --- .trellis/spec/backend/quality-guidelines.md | 6 + .../08-24-inspiration-capture/check.jsonl | 3 + .../tasks/08-24-inspiration-capture/design.md | 35 + .../08-24-inspiration-capture/implement.jsonl | 4 + .../08-24-inspiration-capture/implement.md | 13 + .../tasks/08-24-inspiration-capture/prd.md | 30 + .../tasks/08-24-inspiration-capture/task.json | 26 + .../08-24-inspiration-clients/check.jsonl | 3 + .../tasks/08-24-inspiration-clients/design.md | 22 + .../08-24-inspiration-clients/implement.jsonl | 4 + .../08-24-inspiration-clients/implement.md | 12 + .../tasks/08-24-inspiration-clients/prd.md | 30 + .../tasks/08-24-inspiration-clients/task.json | 26 + .../tasks/08-24-inspiration-flow/check.jsonl | 3 + .../tasks/08-24-inspiration-flow/design.md | 56 ++ .../08-24-inspiration-flow/implement.jsonl | 4 + .../tasks/08-24-inspiration-flow/implement.md | 12 + .trellis/tasks/08-24-inspiration-flow/prd.md | 37 + .../tasks/08-24-inspiration-flow/task.json | 26 + .../08-24-inspiration-plugin/check.jsonl | 4 + .../tasks/08-24-inspiration-plugin/design.md | 56 ++ .../08-24-inspiration-plugin/implement.jsonl | 4 + .../08-24-inspiration-plugin/implement.md | 18 + .../tasks/08-24-inspiration-plugin/prd.md | 58 ++ .../research/plugin-patterns.md | 18 + .../tasks/08-24-inspiration-plugin/task.json | 30 + README.md | 8 +- docs/PLUGIN_API.md | 15 + package.json | 3 +- plugins/inspiration/README.md | 70 ++ plugins/inspiration/config.schema.json | 8 + plugins/inspiration/echolog.plugin.json | 25 + plugins/inspiration/package.json | 34 + plugins/inspiration/src/cli.ts | 34 + plugins/inspiration/src/flow-routes.ts | 381 ++++++++++ plugins/inspiration/src/flow-store.ts | 654 ++++++++++++++++++ plugins/inspiration/src/flow.ts | 211 ++++++ plugins/inspiration/src/index.ts | 85 +++ plugins/inspiration/src/migrations.ts | 134 ++++ plugins/inspiration/src/notifications.ts | 54 ++ plugins/inspiration/src/routes.ts | 474 +++++++++++++ plugins/inspiration/src/schema.ts | 183 +++++ plugins/inspiration/src/selector.ts | 175 +++++ plugins/inspiration/src/store.ts | 301 ++++++++ plugins/inspiration/src/types.ts | 117 ++++ plugins/inspiration/tsconfig.json | 8 + plugins/inspiration/tsup.config.ts | 10 + plugins/inspiration/web/index.js | 348 ++++++++++ pnpm-lock.yaml | 25 + src/cli/index.ts | 479 +++++++++++++ src/core/plugins/registry.ts | 5 + tests/inspiration-capture.test.ts | 425 ++++++++++++ tests/inspiration-clients.test.ts | 467 +++++++++++++ tests/inspiration-flow.test.ts | 510 ++++++++++++++ tests/inspiration.integration.ts | 248 +++++++ 55 files changed, 6028 insertions(+), 3 deletions(-) create mode 100644 .trellis/tasks/08-24-inspiration-capture/check.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-capture/design.md create mode 100644 .trellis/tasks/08-24-inspiration-capture/implement.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-capture/implement.md create mode 100644 .trellis/tasks/08-24-inspiration-capture/prd.md create mode 100644 .trellis/tasks/08-24-inspiration-capture/task.json create mode 100644 .trellis/tasks/08-24-inspiration-clients/check.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-clients/design.md create mode 100644 .trellis/tasks/08-24-inspiration-clients/implement.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-clients/implement.md create mode 100644 .trellis/tasks/08-24-inspiration-clients/prd.md create mode 100644 .trellis/tasks/08-24-inspiration-clients/task.json create mode 100644 .trellis/tasks/08-24-inspiration-flow/check.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-flow/design.md create mode 100644 .trellis/tasks/08-24-inspiration-flow/implement.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-flow/implement.md create mode 100644 .trellis/tasks/08-24-inspiration-flow/prd.md create mode 100644 .trellis/tasks/08-24-inspiration-flow/task.json create mode 100644 .trellis/tasks/08-24-inspiration-plugin/check.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-plugin/design.md create mode 100644 .trellis/tasks/08-24-inspiration-plugin/implement.jsonl create mode 100644 .trellis/tasks/08-24-inspiration-plugin/implement.md create mode 100644 .trellis/tasks/08-24-inspiration-plugin/prd.md create mode 100644 .trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md create mode 100644 .trellis/tasks/08-24-inspiration-plugin/task.json create mode 100644 plugins/inspiration/README.md create mode 100644 plugins/inspiration/config.schema.json create mode 100644 plugins/inspiration/echolog.plugin.json create mode 100644 plugins/inspiration/package.json create mode 100644 plugins/inspiration/src/cli.ts create mode 100644 plugins/inspiration/src/flow-routes.ts create mode 100644 plugins/inspiration/src/flow-store.ts create mode 100644 plugins/inspiration/src/flow.ts create mode 100644 plugins/inspiration/src/index.ts create mode 100644 plugins/inspiration/src/migrations.ts create mode 100644 plugins/inspiration/src/notifications.ts create mode 100644 plugins/inspiration/src/routes.ts create mode 100644 plugins/inspiration/src/schema.ts create mode 100644 plugins/inspiration/src/selector.ts create mode 100644 plugins/inspiration/src/store.ts create mode 100644 plugins/inspiration/src/types.ts create mode 100644 plugins/inspiration/tsconfig.json create mode 100644 plugins/inspiration/tsup.config.ts create mode 100644 plugins/inspiration/web/index.js create mode 100644 tests/inspiration-capture.test.ts create mode 100644 tests/inspiration-clients.test.ts create mode 100644 tests/inspiration-flow.test.ts create mode 100644 tests/inspiration.integration.ts diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index 80ccd8f..dfed849 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -35,6 +35,12 @@ - 崩溃容忍:片段开启即 INSERT,周期 UPDATE(60s),`stopTracker` 收尾在 `lastSeenAt` 而非 `new Date()` - 采样断档检测(`now - lastSampleAt > 3×间隔`)兜住睡眠/合盖,在最后活跃时刻收尾 +### 持久化插件投递任务 + +- 时间 bucket 的唯一 dedupe key 只能防当前 bucket 重复,不能单独承担崩溃恢复:daemon 可能在写入 `reserved` 后、完成外部投递前退出,并在下一 bucket 才重启。创建新投递前必须先认领最旧的 stale `reserved` 行,保留原 dedupe key,并记录 attempt 次数。 +- 恢复认领要在事务中使用短租约、版本/状态前置条件和 `.returning()`;租约内的重复轮询只观察既有投递,不再次调用外部服务。外部服务仍须消费同一个 dedupe key,兜住超出租约的非协作超时。 +- `AbortSignal` 检查不能只放在事务入口。每个可能等待行锁/ advisory lock 的语句返回后、以及任何持久状态变更前后都要再次检查,使 Host 超时释放 non-reentry 后,迟到的事务能回滚而不是继续写入。 + ### 结构化诊断端点 - doctor 类端点失败可返回 503,但响应仍须包含顶层 `error` 以及逐项 diagnostics。CLI 必须保留原始 JSON 错误体,人类模式必须展示逐项检查,两种模式都以非零退出。 diff --git a/.trellis/tasks/08-24-inspiration-capture/check.jsonl b/.trellis/tasks/08-24-inspiration-capture/check.jsonl new file mode 100644 index 0000000..76b82e0 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-capture/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Verify migrations, private schema, and atomic updates"} +{"file":".trellis/spec/backend/error-handling.md","reason":"Verify route status and body contracts"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Backend quality gate"} diff --git a/.trellis/tasks/08-24-inspiration-capture/design.md b/.trellis/tasks/08-24-inspiration-capture/design.md new file mode 100644 index 0000000..38728e3 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-capture/design.md @@ -0,0 +1,35 @@ +# Capture design + +## Ownership + +This task exclusively owns: + +- `plugins/inspiration/{echolog.plugin.json,config.schema.json,package.json,tsconfig.json,tsup.config.ts}` +- `plugins/inspiration/src/{schema.ts,migrations.ts,store.ts,routes.ts}` +- Capture-focused tests under `tests/inspiration-capture.test.ts` + +It MUST NOT edit Flow files, client/Web files, root registry/build files, README, +or shared `plugins/inspiration/src/types.ts` and `index.ts`. + +## Data model + +`inspirations` stores `id`, optimistic `version`, `content`, normalized `tags`, +optional `project`, lifecycle `status` (`inbox|kept|archived`), timestamps, and +`last_surfaced_at`. `inspiration_flow_settings` and +`inspiration_flow_deliveries` are created in the same private migration series +from the parent design so the Flow agent can implement its store independently. + +No foreign key may point outside plugin-owned inspiration tables. + +## API shape + +- `POST /api/plugins/inspiration/inspirations` +- `GET /api/plugins/inspiration/inspirations` +- `GET /api/plugins/inspiration/inspirations/:id` +- `PATCH /api/plugins/inspiration/inspirations/:id` +- `POST /api/plugins/inspiration/inspirations/:id/archive` +- `POST /api/plugins/inspiration/inspirations/:id/restore` + +Mutations return the canonical row. Version conflicts return `409` with +`currentVersion` when available. Search is PostgreSQL `ILIKE` over content; +tags/projects/statuses are exact deterministic filters. diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.jsonl b/.trellis/tasks/08-24-inspiration-capture/implement.jsonl new file mode 100644 index 0000000..a1374f3 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-capture/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Plugin schema, migration, transaction, and optimistic-update conventions"} +{"file":".trellis/spec/backend/error-handling.md","reason":"Canonical route validation and structured error conventions"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Forbidden read-check-write and update-spread patterns"} +{"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Established bundled-plugin storage and route patterns"} diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.md b/.trellis/tasks/08-24-inspiration-capture/implement.md new file mode 100644 index 0000000..0fc0d02 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-capture/implement.md @@ -0,0 +1,13 @@ +# Capture implementation plan + +- [x] Create plugin package metadata, manifest, and strict config schema. +- [x] Add private Drizzle schema and immutable SQL migrations for inspirations, + Flow settings, and Flow deliveries. +- [x] Implement store CRUD/filter/history with atomic expected-version writes. +- [x] Implement route validation and canonical response/error envelopes. +- [x] Add unit tests with store fakes plus guarded PostgreSQL integration tests + where useful. +- [x] Run package typecheck and focused tests; report changed files only. + +Validation: `pnpm --filter @echolog/plugin-inspiration typecheck` and +`pnpm exec tsx --test tests/inspiration-capture.test.ts`. diff --git a/.trellis/tasks/08-24-inspiration-capture/prd.md b/.trellis/tasks/08-24-inspiration-capture/prd.md new file mode 100644 index 0000000..58168d9 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-capture/prd.md @@ -0,0 +1,30 @@ +# Inspiration capture and organization (#33) + +## Goal + +Provide durable, standalone inspiration capture and organization without an +active EchoLog record and without any Schedule dependency. + +## Requirements + +- Own the plugin manifest/config package skeleton plus private inspiration and + Flow table migrations/schema needed by the complete plugin. +- Create inspirations with content, normalized tags, optional free-form + project, and lifecycle status `inbox` or `kept`. +- List and search by text, tag, project, lifecycle status, archived state, + creation time, pagination cursor/limit, and deterministic ordering. +- Fetch and version-guard edits; archive/restoration are explicit lifecycle + operations and historical rows remain queryable. +- Validation happens at the route boundary; persistence updates use field + whitelists and atomic `WHERE id = ... AND version = expectedVersion` writes. +- No API or table references Core records, tasks, schedules, or another plugin. + +## Acceptance Criteria + +- [x] Canonical `/api/plugins/inspiration/inspirations*` endpoints implement + create/list/get/update/archive/restore with structured 400/404/409 errors. +- [x] Capture succeeds with no active Core record. +- [x] Text/tag/project/status/archive filters and history ordering are tested. +- [x] Concurrent stale `expectedVersion` updates cannot overwrite newer data. +- [x] Migrations are ordered, immutable, idempotent, and private to the plugin. +- [x] Tests prove no schedule API call or cross-plugin relation exists. diff --git a/.trellis/tasks/08-24-inspiration-capture/task.json b/.trellis/tasks/08-24-inspiration-capture/task.json new file mode 100644 index 0000000..f091446 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-capture/task.json @@ -0,0 +1,26 @@ +{ + "id": "inspiration-capture", + "name": "inspiration-capture", + "title": "Inspiration capture and organization (#33)", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": "plugin package metadata, schema, migrations, capture store/routes/tests", + "package": null, + "priority": "P2", + "creator": "sc", + "assignee": "sc", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/inspiration-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-24-inspiration-plugin", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-24-inspiration-clients/check.jsonl b/.trellis/tasks/08-24-inspiration-clients/check.jsonl new file mode 100644 index 0000000..e3710be --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-clients/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/cli-agent-contract.md","reason":"Verify CLI agent-facing compatibility"} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Verify ready-only native Web contribution and escaping"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Frontend quality gate"} diff --git a/.trellis/tasks/08-24-inspiration-clients/design.md b/.trellis/tasks/08-24-inspiration-clients/design.md new file mode 100644 index 0000000..e409850 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-clients/design.md @@ -0,0 +1,22 @@ +# Client design + +## Ownership + +This task exclusively owns: + +- `plugins/inspiration/src/cli.ts` +- `plugins/inspiration/web/index.js` +- Inspiration command registration in `src/cli/index.ts` +- Client/Web/report tests in `tests/inspiration-clients.test.ts` + +It MUST NOT edit backend stores/routes/schema, package manifest/config, +`plugins/inspiration/src/types.ts` or `index.ts`, root registry/build files, or +README. + +## Boundary + +CLI and Web know only HTTP DTOs. They do not select candidates, infer status, +resolve active records, or perform schedule conversion. Web follows the Shell +contribution contract (`faces/load/loadLive/renderFace/handleAction/unmount`). +The plugin `index.ts` integration owned by the parent will register the report +section using a backend summary method exposed by the agreed service contract. diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.jsonl b/.trellis/tasks/08-24-inspiration-clients/implement.jsonl new file mode 100644 index 0000000..563f346 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-clients/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/cli-agent-contract.md","reason":"HTTP-thin CLI, raw JSON, help, and exit-code contract"} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Native JS contribution, escaping, and event delegation conventions"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Keep DTOs and backend policy consistent"} +{"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Existing CLI/Web contribution mechanics"} diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.md b/.trellis/tasks/08-24-inspiration-clients/implement.md new file mode 100644 index 0000000..5a4fbfc --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-clients/implement.md @@ -0,0 +1,12 @@ +# Client implementation plan + +- [x] Add typed CLI contribution metadata and full `el inspiration` HTTP-thin + command tree in the Core CLI composition point. +- [x] Add ready-only native-JS Inbox and Flow contribution with escaped output. +- [x] Add client/Web tests for paths, JSON/error behavior, actions, and absence + of schedule semantics. +- [x] Add a report renderer/helper or contract consumed by parent integration. +- [x] Run focused tests and typecheck; report changed files only. + +Validation: `pnpm exec tsx --test tests/inspiration-clients.test.ts`, +`pnpm typecheck`, and CLI help smoke checks. diff --git a/.trellis/tasks/08-24-inspiration-clients/prd.md b/.trellis/tasks/08-24-inspiration-clients/prd.md new file mode 100644 index 0000000..561921c --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-clients/prd.md @@ -0,0 +1,30 @@ +# Inspiration CLI Web and report clients + +## Goal + +Expose Capture and Flow through thin HTTP CLI commands, ready-only Web pages, +and a concise daily-report section without duplicating backend policy. + +## Requirements + +- Top-level `el inspiration` supports capture, inbox/list, show, edit, + archive/restore, Flow next/outcome, settings, and history/deliveries. +- Global `--json` prints raw API success/error bodies and all failures are + non-zero; help documents values and examples. +- Web contributes Inbox and Flow faces through the existing plugin Web Host, + escapes all dynamic text/attributes, and delegates all validation/selection + to canonical plugin APIs. +- Web assets are imported only for a ready plugin and unmounted when unavailable. +- Daily report summarizes captures/surfaces/outcomes without embedding stored + inspiration bodies by default. + +## Acceptance Criteria + +- [x] CLI is an HTTP-thin client with raw `--json`, non-zero errors, and complete + command help. +- [x] Web Inbox can capture/filter/edit/archive and Flow can next/respond using + only `/api/plugins/inspiration/*`. +- [x] Disabled/degraded plugins add no Web faces and return structured errors + through CLI/API. +- [x] Dynamic Web text is escaped and no schedule UI/action exists. +- [x] Daily report contribution is covered by tests. diff --git a/.trellis/tasks/08-24-inspiration-clients/task.json b/.trellis/tasks/08-24-inspiration-clients/task.json new file mode 100644 index 0000000..e9b8f7b --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-clients/task.json @@ -0,0 +1,26 @@ +{ + "id": "inspiration-clients", + "name": "inspiration-clients", + "title": "Inspiration CLI Web and report clients", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": "CLI, Web contribution, report-facing helper, client tests", + "package": null, + "priority": "P2", + "creator": "sc", + "assignee": "sc", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/inspiration-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-24-inspiration-plugin", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/08-24-inspiration-flow/check.jsonl new file mode 100644 index 0000000..947916f --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-flow/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Review transaction and dedupe correctness"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Review job timeout/non-reentry and failure recovery"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review Flow state/lifecycle separation"} diff --git a/.trellis/tasks/08-24-inspiration-flow/design.md b/.trellis/tasks/08-24-inspiration-flow/design.md new file mode 100644 index 0000000..86690d8 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-flow/design.md @@ -0,0 +1,56 @@ +# Flow design + +## Ownership + +This task exclusively owns: + +- `plugins/inspiration/src/{flow-store.ts,selector.ts,flow.ts,flow-routes.ts,notifications.ts}` +- Flow-focused tests under `tests/inspiration-flow.test.ts` + +It MUST NOT edit Capture/schema/migration/package files, clients/Web, root +registry/build files, README, shared `types.ts`, or plugin `index.ts`. + +## Notification boundary + +The only host dependency is: + +```ts +export interface NotificationsSendService { + send( + input: { + title: string; + body: string; + dedupeKey: string; + data: { pluginId: "inspiration"; inspirationId: string; deliveryId: string }; + }, + signal?: AbortSignal + ): Promise<{ delivered: boolean; channel?: string }>; +} +``` + +It is resolved lazily with +`context.service("notifications.send")`. Tests mock +this service. This branch does not implement or import the Core notifier and +does not widen the SDK. + +## Selection and atomicity + +Pure policy code evaluates local time/quiet hours and returns eligibility +reasons. The store transaction locks/reserves one candidate, writes a unique +delivery dedupe key, and advances `last_surfaced_at` with an expected inspiration +version. Repeated scheduler buckets or manual idempotency keys return the +existing delivery rather than double-send. + +The deterministic sort is `last_surfaced_at NULLS FIRST`, then `created_at`, +then `id`. Scheduled calls respect quiet hours and `enabled`; manual calls may +bypass only those two gates, never cooldown, snooze, filters, or daily cap. + +## Restart/failure semantics + +The ledger is source of truth. A reserved row survives daemon restart. A send +failure is finalized as `failed`; a later dedupe bucket can retry the same +inspiration if still eligible. Before selecting for a new scheduled bucket, the +store claims the oldest stale `reserved` delivery with a short lease and +increments its durable attempt count. This recovers work even when restart +crosses an interval boundary without letting an immediate repeated poll send +twice. No prompt/reply/screenshot body is stored. diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/08-24-inspiration-flow/implement.jsonl new file mode 100644 index 0000000..a905377 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-flow/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Transactional delivery reservation and expected-version outcomes"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Non-reentry, timeout, and durable job conventions"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Selector, delivery, notification, and API boundary design"} +{"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Host job/service and plugin persistence research"} diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.md b/.trellis/tasks/08-24-inspiration-flow/implement.md new file mode 100644 index 0000000..32b023f --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-flow/implement.md @@ -0,0 +1,12 @@ +# Flow implementation plan + +- [x] Define local notification adapter and service-level error behavior. +- [x] Implement pure selector policy and eligibility explanations. +- [x] Implement Flow settings, candidate reservation, ledger, delivery, snooze, + and outcome persistence with transactions/version guards. +- [x] Implement manual/settings/delivery routes and scheduled job factory. +- [x] Test policies, dedupe/restart/failure/concurrency, abort, and store mocks. +- [x] Run package typecheck and focused tests; report changed files only. + +Validation: `pnpm --filter @echolog/plugin-inspiration typecheck` and +`pnpm exec tsx --test tests/inspiration-flow.test.ts`. diff --git a/.trellis/tasks/08-24-inspiration-flow/prd.md b/.trellis/tasks/08-24-inspiration-flow/prd.md new file mode 100644 index 0000000..2cbe2e6 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-flow/prd.md @@ -0,0 +1,37 @@ +# Inspiration Flow surfacing (#34) + +## Goal + +Resurface eligible inspirations through one deterministic selector shared by +manual and scheduled Flow while keeping delivery state separate from the +inspiration lifecycle. + +## Requirements + +- Settings are database-backed, singleton, versioned, and cover enabled state, + interval, quiet hours, cooldown, daily cap, default snooze, and optional + lifecycle/tag/project filters. +- Manual `next` and the scheduled job call the same selector and atomic reserve + operation. Selection is explainable: oldest `lastSurfacedAt` (never surfaced + first), then oldest creation time and stable id. +- Eligibility excludes archived inspirations, snoozed deliveries, cooldown + windows, disallowed quiet hours for scheduled delivery, and daily-cap excess. +- Delivery ledger records source, unique dedupe key, attempts/status, + notification result/failure, surfaced time, snooze, outcome, and version. +- Outcomes are exactly `viewed`, `continued`, `kept`, `later`, `archived`. + `later` only updates delivery snooze; `kept`/`archived` update the inspiration + lifecycle atomically with the outcome using expected versions. +- Notifications use the local `notifications.send` interface and failures are + recorded without corrupting inspiration lifecycle or preventing later jobs. + +## Acceptance Criteria + +- [x] Manual and scheduled selection produce the same candidate for the same + store snapshot and explain why candidates were excluded. +- [x] Quiet hours (including overnight ranges), cooldown, filters, daily limit, + snooze, duplicate polling, daemon restart, and empty inbox are tested. +- [x] Reservation/delivery/outcome writes are atomic and dedupe-safe. +- [x] Notification failure creates a failed ledger entry and remains retryable. +- [x] `later` never changes inspiration `status`; concurrent stale outcomes + return a conflict. +- [x] Job behavior remains safe under Host non-reentry and timeout/abort. diff --git a/.trellis/tasks/08-24-inspiration-flow/task.json b/.trellis/tasks/08-24-inspiration-flow/task.json new file mode 100644 index 0000000..9a0fd30 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-flow/task.json @@ -0,0 +1,26 @@ +{ + "id": "inspiration-flow", + "name": "inspiration-flow", + "title": "Inspiration Flow surfacing (#34)", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": "selector, flow store/service/routes/job/notification contract/tests", + "package": null, + "priority": "P2", + "creator": "sc", + "assignee": "sc", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/inspiration-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-24-inspiration-plugin", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-24-inspiration-plugin/check.jsonl b/.trellis/tasks/08-24-inspiration-plugin/check.jsonl new file mode 100644 index 0000000..ef941c2 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-plugin/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Atomic transitions, jobs, and server validation review"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Web contribution quality review"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Full-stack contract consistency review"} +{"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Compare implementation with established plugin patterns"} diff --git a/.trellis/tasks/08-24-inspiration-plugin/design.md b/.trellis/tasks/08-24-inspiration-plugin/design.md new file mode 100644 index 0000000..ec9d306 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-plugin/design.md @@ -0,0 +1,56 @@ +# Inspiration integration design + +## Architecture + +```text +CLI / ready-only Web + | +/api/plugins/inspiration/* + | +Capture routes/store ---- private inspirations table + | +Flow routes/job -> shared selector -> private settings/delivery ledger + | + PluginContext.service("notifications.send") +``` + +Schedule, Core records, and other plugin tables are outside every boundary. + +## Shared contracts and root ownership + +The parent/main session exclusively owns: + +- `plugins/inspiration/src/types.ts` — DTO/domain interfaces shared by agents. +- `plugins/inspiration/src/index.ts` — composes Capture and Flow, registers job + and report, and lazily passes the notification service. +- `src/core/plugins/registry.ts`, root `package.json`, lockfile, Web asset + registry, README/docs, and Trellis/GitHub tracking. + +No implementation agent may edit another agent's files. Integration changes +wait until all three implementation agents finish. + +## Domain split + +Inspiration lifecycle (`inbox|kept|archived`, version, content/tags/project) +is independent from Flow delivery (`reserved|sent|failed|acted`, outcome, +snoozedUntil). `later` cannot touch lifecycle. `kept` and `archived` are the +only Flow outcomes that deliberately change lifecycle, in the same transaction +as delivery outcome and guarded by both expected versions. + +## APIs + +All routes are canonical `/api/plugins/inspiration/*`. DTO dates are ISO 8601. +Errors retain top-level `error`; validation uses 400, missing rows 404, and +optimistic/dedupe conflicts 409 with structured version context. + +## Rollout and dependency + +The plugin is bundled and enabled by default for capture. The Flow send service +is resolved only when a notification is attempted, so missing notification +capability does not disable capture. A missing or failed service call finalizes +the delivery as failed and is visible in diagnostics/ledger. Once the separate +notifications worktree registers `notifications.send`, no plugin code change +should be required. + +Rollback is removal from the bundled registry/config; plugin-owned tables are +left intact to preserve user data. diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl b/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl new file mode 100644 index 0000000..fc2b112 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/index.md","reason":"Backend pre-development and quality entry point"} +{"file":".trellis/spec/frontend/index.md","reason":"Frontend pre-development and quality entry point"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Plugin spans persistence, HTTP, CLI, Web, jobs, and reports"} +{"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Repository-specific bundled-plugin research"} diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.md b/.trellis/tasks/08-24-inspiration-plugin/implement.md new file mode 100644 index 0000000..0c94682 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-plugin/implement.md @@ -0,0 +1,18 @@ +# Inspiration parent implementation plan + +- [x] Finalize shared DTO/domain types and file ownership. +- [x] Activate parent and all children after context validation. +- [x] In parallel dispatch Capture, Flow, and Clients SOL High agents with + explicit Active task paths and non-overlapping ownership. +- [x] Integrate package `index.ts`, root workspace dependency/build registry, + Web assets, report section, and public docs. +- [x] Synchronize README and GitHub #33/#34 scope/tracking. +- [x] Run focused, full test, typecheck, build, Trellis validate, and absence + searches for schedule/prompt/reply/screenshot persistence. +- [x] Dispatch an independent SOL High check agent against the latest diff; + fix findings and rerun the full suite. +- [x] Review/update specs if a reusable bundled-plugin pattern was learned. +- [x] Commit coherent changes on `codex/inspiration-plugin` and record session. + +Rollback points: before root registry integration; before docs/Issue update; +before commit. Never merge another branch. diff --git a/.trellis/tasks/08-24-inspiration-plugin/prd.md b/.trellis/tasks/08-24-inspiration-plugin/prd.md new file mode 100644 index 0000000..da5c8c2 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-plugin/prd.md @@ -0,0 +1,58 @@ +# Inspiration bundled plugin + +## Goal + +Deliver one first-party `inspiration` bundled plugin that covers GitHub #33 +(capture and organization) and #34 (Flow resurfacing) as two phases of one +product, with independently verifiable Capture, Flow, and client deliverables. + +## Requirements + +- The plugin is one workspace package at `plugins/inspiration` with private + tables, migrations, stores, canonical routes, jobs, CLI, Web, and an + optional daily-report contribution. +- Inspiration is completely independent from Schedule and Core records. It + MUST NOT query, create, convert, link, or otherwise call schedule APIs, and + MUST NOT add cross-plugin or Core foreign-key relationships. +- Capture works without an active Core record and supports inbox, organization, + editing, tags, free-form project grouping, lifecycle-status filters, + full-text search, archive, and history. +- Flow manual `next` and scheduled delivery share one deterministic, + explainable selector. The first version uses no AI or embeddings. +- Flow enforces cooldown, quiet hours, daily limit, snooze, + `lastSurfacedAt`, a delivery ledger, unique dedupe keys, and explicit user + outcomes. Snooze changes delivery eligibility only and never changes the + inspiration lifecycle. +- Allowed Flow actions are view, continue editing, keep, later, and archive. + Task/schedule creation and scheduling are explicitly out of scope. +- Flow notifications use `PluginContext.service("notifications.send")` through + the narrow local TypeScript contract documented in `design.md`. The Core + notifier and Host/SDK public contract are not copied into this branch. +- No screenshots, prompts, replies, or model reasoning are stored. +- Web contributions load only when the plugin is ready. CLI commands remain + HTTP-thin and preserve global `--json` raw-response/error behavior. +- State transitions are atomic and version-guarded with `expectedVersion`. + +## Child Task Map + +- `08-24-inspiration-capture` — #33 persistence, CRUD, search/filter/archive. +- `08-24-inspiration-flow` — #34 selector, settings, ledger, job, notification. +- `08-24-inspiration-clients` — shared CLI, Web Inbox/Flow, report, client tests. + +## Acceptance Criteria + +- [x] All three child tasks meet their acceptance criteria and integrate as one + `inspiration` plugin. +- [x] README, plugin documentation, Trellis, GitHub #33, and GitHub #34 agree + on the single-plugin boundary and the removal of schedule conversion scope. +- [x] Disabled/degraded behavior, job non-reentry/timeout, restart/repeated + polling, notification failure, selector policies, and concurrent updates are + covered by automated tests. +- [x] `pnpm test`, `pnpm typecheck`, and `pnpm build` pass. +- [x] Trellis validation/spec review is complete and changes are committed on + `codex/inspiration-plugin` without merging any other branch. + +## Authorization + +The source request explicitly authorizes the full Trellis development flow, +implementation, validation, documentation synchronization, and commit. diff --git a/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md b/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md new file mode 100644 index 0000000..cc42da7 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md @@ -0,0 +1,18 @@ +# Bundled plugin research notes + +- `docs/PLUGIN_API.md` and `packages/plugin-sdk` define API v1. Routes are + discovered while disabled/degraded but Host checks ready state before handler. +- `PluginHost` owns non-overlap and rejecting timeout races for registered jobs; + plugin jobs still need durable idempotency because daemon restarts lose memory. +- `screen-time` demonstrates private SQL migration, store lifecycle, report + contribution, and ready-only Web module activation. +- `tmux-status` demonstrates strict boundary validation, canonical routes, + persistence dedupe, job registration, and mockable PluginContext tests. +- CLI commands are composed in `src/cli/index.ts`; plugin `src/cli.ts` currently + supplies contribution metadata only, so the new command remains a thin HTTP + adapter at the composition point. +- Web Shell calls contribution `load`, `loadLive`, `faces`, `renderFace`, and + `handleAction`, and imports a module only when `/api/plugins` reports ready. +- No current Host service named `notifications.send` exists in this branch. + Inspiration therefore defines only a local generic interface and resolves the + service lazily; the separate notifications worktree owns Host wiring. diff --git a/.trellis/tasks/08-24-inspiration-plugin/task.json b/.trellis/tasks/08-24-inspiration-plugin/task.json new file mode 100644 index 0000000..1b798f4 --- /dev/null +++ b/.trellis/tasks/08-24-inspiration-plugin/task.json @@ -0,0 +1,30 @@ +{ + "id": "inspiration-plugin", + "name": "inspiration-plugin", + "title": "Inspiration bundled plugin", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": "plugins/inspiration + bundled registry/build/docs tracking", + "package": null, + "priority": "P2", + "creator": "sc", + "assignee": "sc", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/inspiration-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "08-24-inspiration-capture", + "08-24-inspiration-flow", + "08-24-inspiration-clients" + ], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index c894ccc..7d96d66 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - **父子任务**:一个大任务可挂多层小任务;服务端防止自指/成环,CLI 与 Web 可创建、查询并查看直接子任务进度 - **笔记**:给任意记录追加 `note | blocker | next` - **补录与编辑**:`el add --at --for`、`el edit` -- **内置插件**:screen-time 采样和追溯分类前台应用;tmux-status 通过外部 CLI 提供结构化 pane/资源观测,并以 v3 合约持久化已验证的 Agent conversation↔pane 恢复映射 +- **内置插件**:screen-time 采样和追溯分类前台应用;tmux-status 通过外部 CLI 提供结构化 pane/资源观测,并以 v3 合约持久化已验证的 Agent conversation↔pane 恢复映射;Inspiration 覆盖独立灵感捕捉、整理与 Flow 回顾 - **汇总与日报**:今日/指定日汇总、日报 Markdown 生成、可同步到指定目录 - **提醒**(可选):任务超时、空闲提醒、macOS 通知 + ntfy 推送到手机 - **四个入口,一套 REST API**:免构建的 Web 控制台、`el` CLI、本地 stdio MCP、HTTP API(`docs/API.md`) @@ -90,6 +90,7 @@ el report # 输出日报 Markdown ```bash el status --json # 今日概览 + 活跃任务 el log --json -n 50 # 历史记录 +el inspiration list --json # 灵感 Inbox 与筛选历史 el screen --json # 今日屏幕使用(macOS) el plugins list --json # 内置插件清单与状态 el tmux status --json # tmux-status 原始快照(插件默认禁用) @@ -111,6 +112,7 @@ el tmux status --json # tmux-status 原始快照(插件默认禁用) | `database` | PostgreSQL 连接(与 docker-compose 默认值对应) | | `plugins.screen-time` | 屏幕采样开关、频率与空闲阈值(默认启用) | | `plugins.tmux-status` | 外部 executable、超时、采样频率、异常阈值,以及 v3 Agent conversation↔pane 恢复映射(默认禁用) | +| `plugins.inspiration` | 独立灵感捕捉与 Flow 回顾插件开关;Flow 规则保存在插件私有 settings 中 | | `sync` | 日报 Markdown 同步目标目录 | | `notifications` | macOS 通知、ntfy 推送、超时/空闲/日报提醒规则 | @@ -158,7 +160,8 @@ EchoLog Core (records, notes, subtasks, reports, sync) | Bundled Plugin API v1 |-- screen-time - `-- tmux-status -> external tmux-status executable + |-- tmux-status -> external tmux-status executable + `-- inspiration -> notifications.send (lazy Host service) Codex Plugin Skills -> el --json ---------^ Codex MCP host ------> el mcp ------------^ @@ -185,6 +188,7 @@ EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 m - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 +- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/08-24-inspiration-plugin/)。 插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。 diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 31d438a..2adc8b1 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -191,6 +191,21 @@ then delegates data loading, face descriptions, rendering and actions. A module failure removes only that contribution. Disabled plugins do not add navigation or pages. +## Inspiration notification dependency + +The bundled `inspiration` plugin owns capture, organization, and deterministic +Flow resurfacing under `/api/plugins/inspiration/*`. Its inspiration lifecycle +and Flow delivery ledger are separate; snoozing a delivery MUST NOT change the +inspiration's kept/archived state. The plugin has no Schedule/Core-record API or +table relationship. + +Flow resolves the named service `notifications.send` lazily through +`PluginContext.service()`. The plugin's local interface accepts a title, body, +dedupe key, and `{pluginId, inspirationId, deliveryId}` metadata and returns a +delivered flag plus optional channel. The notification service is Host-owned; +the plugin MUST NOT import or copy the Core notifier. Missing/failing delivery +is recorded in the plugin ledger while capture remains available. + ## Compatibility policy API v1 changes are additive. A breaking SDK, lifecycle or manifest change diff --git a/package.json b/package.json index 0658904..a9ce78c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "dev": "tsx src/server/app.ts", - "build": "pnpm --filter @echolog/plugin-sdk build && pnpm --filter @echolog/plugin-screen-time build && pnpm --filter @echolog/plugin-tmux-status build && tsup", + "build": "pnpm --filter @echolog/plugin-sdk build && pnpm --filter @echolog/plugin-screen-time build && pnpm --filter @echolog/plugin-tmux-status build && pnpm --filter @echolog/plugin-inspiration build && tsup", "build:macos-capture": "bash scripts/build-macos-capture.sh", "build:macos-release": "pnpm build && pnpm build:macos-capture", "package:macos": "bash scripts/package-release.sh --version 0.2.0 --adhoc", @@ -24,6 +24,7 @@ "@echolog/plugin-screen-time": "workspace:*", "@echolog/plugin-sdk": "workspace:*", "@echolog/plugin-tmux-status": "workspace:*", + "@echolog/plugin-inspiration": "workspace:*", "@fastify/cors": "^11.0.0", "@fastify/static": "^8.1.0", "@modelcontextprotocol/sdk": "1.30.0", diff --git a/plugins/inspiration/README.md b/plugins/inspiration/README.md new file mode 100644 index 0000000..f84a9b3 --- /dev/null +++ b/plugins/inspiration/README.md @@ -0,0 +1,70 @@ +# Inspiration bundled plugin + +Inspiration is one bundled plugin with two product phases: + +- GitHub #33: capture, Inbox organization, search/filter, archive, and history. +- GitHub #34: deterministic manual/scheduled Flow resurfacing and outcomes. + +It is intentionally independent from EchoLog records and Schedule. The plugin +does not query, create, convert, or link schedules or Core records, and its only +foreign key is private to its own inspiration/delivery tables. + +## Routes + +Canonical routes live under `/api/plugins/inspiration/*`: + +- `POST|GET /inspirations` +- `GET|PATCH /inspirations/:id` +- `POST /inspirations/:id/archive` +- `POST /inspirations/:id/restore` +- `POST /flow/next` +- `GET|PATCH /flow/settings` +- `GET /flow/deliveries` +- `POST /flow/deliveries/:id/outcome` + +Mutations that change existing state require `expectedVersion`. Inspiration +lifecycle (`inbox`, `kept`, `archived`) is separate from Flow delivery state. +In particular, the `later` outcome only snoozes a delivery and does not change +the inspiration lifecycle. + +## Flow policy + +Manual `next` and the scheduled job use the same deterministic selector: +never-surfaced inspirations first, then oldest `lastSurfacedAt`, creation time, +and id. Settings control lifecycle/tag/project filters, cooldown, quiet hours, +daily cap, and default snooze. The delivery ledger and unique dedupe keys make +repeated polling and daemon restarts observable and idempotent. Each delivery +tracks its notification attempt count. A short reservation lease prevents an +immediate duplicate poll from sending twice, while the scheduler claims the +oldest stale `reserved` delivery before selecting a new candidate, including +after restart into a different interval bucket. + +The first version uses no AI or embeddings and stores no screenshots, prompt, +reply, reasoning, or terminal content. + +## Notification dependency + +Flow resolves exactly one host service lazily: + +```ts +interface NotificationsSendService { + send( + input: { + title: string; + body: string; + dedupeKey: string; + data: { + pluginId: "inspiration"; + inspirationId: string; + deliveryId: string; + }; + }, + signal?: AbortSignal + ): Promise<{ delivered: boolean; channel?: string }>; +} +``` + +The service name is `notifications.send`. Host wiring belongs to the separate +notifications implementation. This package neither imports nor copies the Core +notifier. A missing or failed service is recorded as a failed Flow delivery; +Capture remains available. diff --git a/plugins/inspiration/config.schema.json b/plugins/inspiration/config.schema.json new file mode 100644 index 0000000..7db1a72 --- /dev/null +++ b/plugins/inspiration/config.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://echolog.local/plugins/inspiration/config.schema.json", + "title": "Inspiration plugin configuration", + "type": "object", + "additionalProperties": false, + "properties": {} +} diff --git a/plugins/inspiration/echolog.plugin.json b/plugins/inspiration/echolog.plugin.json new file mode 100644 index 0000000..4d7142e --- /dev/null +++ b/plugins/inspiration/echolog.plugin.json @@ -0,0 +1,25 @@ +{ + "manifestVersion": 1, + "id": "inspiration", + "version": "1.0.0", + "apiVersion": "1", + "displayName": "Inspiration", + "description": "Standalone inspiration capture, organization, and deterministic resurfacing", + "entries": { + "server": "./dist/index.js", + "cli": "./dist/cli.js", + "web": "/plugins/inspiration/index.js" + }, + "capabilities": [ + "inspiration-capture", + "inspiration-flow", + "daily-report" + ], + "permissions": [ + "database:plugin" + ], + "requires": { + "coreApi": "^1.0.0" + }, + "configSchema": "./config.schema.json" +} diff --git a/plugins/inspiration/package.json b/plugins/inspiration/package.json new file mode 100644 index 0000000..281363c --- /dev/null +++ b/plugins/inspiration/package.json @@ -0,0 +1,34 @@ +{ + "name": "@echolog/plugin-inspiration", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "web", + "echolog.plugin.json", + "config.schema.json" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@echolog/plugin-sdk": "workspace:*", + "drizzle-orm": "^0.44.0", + "nanoid": "^5.1.5", + "postgres": "^3.4.7" + }, + "devDependencies": { + "tsup": "^8.5.0", + "typescript": "^5.8.3" + } +} diff --git a/plugins/inspiration/src/cli.ts b/plugins/inspiration/src/cli.ts new file mode 100644 index 0000000..097eb6d --- /dev/null +++ b/plugins/inspiration/src/cli.ts @@ -0,0 +1,34 @@ +import type { DailyInspirationSummary, FlowOutcome } from "./types.js"; + +export const inspirationCliContribution = { + command: "inspiration", + apiPrefix: "/api/plugins/inspiration", +} as const; + +const OUTCOME_LABELS: Record = { + viewed: "查看", + continued: "继续编辑", + kept: "保留", + later: "稍后", + archived: "归档", +}; +const OUTCOME_ORDER = Object.keys(OUTCOME_LABELS) as FlowOutcome[]; + +/** Render aggregate counts only; inspiration bodies never enter daily reports. */ +export function renderInspirationDailySummary( + summary: DailyInspirationSummary +): string | null { + const outcomes = OUTCOME_ORDER.flatMap((outcome) => { + const count = summary.outcomes[outcome]; + return typeof count === "number" && count > 0 + ? [`${OUTCOME_LABELS[outcome]} ${count}`] + : []; + }); + if (summary.captured === 0 && summary.surfaced === 0 && outcomes.length === 0) { + return null; + } + return [ + `捕捉 ${summary.captured} 条,Flow 浮现 ${summary.surfaced} 次。`, + ...(outcomes.length > 0 ? [`结果:${outcomes.join("、")}。`] : []), + ].join("\n"); +} diff --git a/plugins/inspiration/src/flow-routes.ts b/plugins/inspiration/src/flow-routes.ts new file mode 100644 index 0000000..dd7ec77 --- /dev/null +++ b/plugins/inspiration/src/flow-routes.ts @@ -0,0 +1,381 @@ +import type { + PluginHttpRequest, + PluginHttpResponse, + PluginRoute, +} from "@echolog/plugin-sdk"; +import { FlowStoreError } from "./flow-store.js"; +import type { FlowService } from "./flow.js"; +import type { + FlowOutcome, + FlowOutcomeInput, + FlowSettingsUpdate, +} from "./types.js"; + +const DELIVERY_ID_RE = /^[A-Za-z0-9_-]{1,80}$/; +const FLOW_OUTCOMES = new Set([ + "viewed", + "continued", + "kept", + "later", + "archived", +]); +const SETTINGS_KEYS = [ + "expectedVersion", + "enabled", + "intervalMinutes", + "quietStartMinute", + "quietEndMinute", + "cooldownMinutes", + "dailyLimit", + "defaultSnoozeMinutes", + "statuses", + "tags", + "projects", +] as const; + +type Validation = { ok: true; value: T } | { ok: false; error: string }; + +function response(statusCode: number, body: unknown): PluginHttpResponse { + return { statusCode, body }; +} + +function record(body: unknown): Validation> { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, error: "body must be an object" }; + } + return { ok: true, value: body as Record }; +} + +function hasExactKeys( + value: Record, + allowed: readonly string[] +): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function integer( + value: unknown, + name: string, + minimum: number, + maximum: number +): Validation { + return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum + ? { ok: true, value: Number(value) } + : { + ok: false, + error: `${name} must be an integer from ${minimum} to ${maximum}`, + }; +} + +function stringArray( + value: unknown, + name: string, + maximumItems: number, + maximumLength: number +): Validation { + if ( + !Array.isArray(value) || + value.length > maximumItems || + value.some( + (item) => + typeof item !== "string" || + item.trim().length === 0 || + item.length > maximumLength + ) + ) { + return { + ok: false, + error: `${name} must contain at most ${maximumItems} non-empty strings up to ${maximumLength} characters`, + }; + } + const normalized = value.map((item) => (item as string).trim()); + if (new Set(normalized).size !== normalized.length) { + return { ok: false, error: `${name} must not contain duplicates` }; + } + return { ok: true, value: normalized }; +} + +export function validateSettingsUpdate( + body: unknown +): Validation { + const object = record(body); + if (!object.ok) return object; + if ( + !hasExactKeys(object.value, SETTINGS_KEYS) || + SETTINGS_KEYS.some((key) => !(key in object.value)) + ) { + return { + ok: false, + error: `body must contain exactly ${SETTINGS_KEYS.join(", ")}`, + }; + } + if (typeof object.value.enabled !== "boolean") { + return { ok: false, error: "enabled must be a boolean" }; + } + const expectedVersion = integer( + object.value.expectedVersion, + "expectedVersion", + 1, + 2_147_483_647 + ); + if (!expectedVersion.ok) return expectedVersion; + const intervalMinutes = integer( + object.value.intervalMinutes, + "intervalMinutes", + 1, + 10_080 + ); + if (!intervalMinutes.ok) return intervalMinutes; + const quietStartMinute = integer( + object.value.quietStartMinute, + "quietStartMinute", + 0, + 1_439 + ); + if (!quietStartMinute.ok) return quietStartMinute; + const quietEndMinute = integer( + object.value.quietEndMinute, + "quietEndMinute", + 0, + 1_439 + ); + if (!quietEndMinute.ok) return quietEndMinute; + const cooldownMinutes = integer( + object.value.cooldownMinutes, + "cooldownMinutes", + 0, + 525_600 + ); + if (!cooldownMinutes.ok) return cooldownMinutes; + const dailyLimit = integer( + object.value.dailyLimit, + "dailyLimit", + 1, + 1_000 + ); + if (!dailyLimit.ok) return dailyLimit; + const defaultSnoozeMinutes = integer( + object.value.defaultSnoozeMinutes, + "defaultSnoozeMinutes", + 1, + 525_600 + ); + if (!defaultSnoozeMinutes.ok) return defaultSnoozeMinutes; + + const statuses = stringArray(object.value.statuses, "statuses", 2, 10); + if (!statuses.ok) return statuses; + if ( + statuses.value.length === 0 || + statuses.value.some((status) => status !== "inbox" && status !== "kept") + ) { + return { ok: false, error: "statuses must contain inbox and/or kept" }; + } + const tags = stringArray(object.value.tags, "tags", 50, 50); + if (!tags.ok) return tags; + const normalizedTags = tags.value.map((tag) => tag.toLowerCase()).sort(); + if (new Set(normalizedTags).size !== normalizedTags.length) { + return { ok: false, error: "tags must not contain duplicates" }; + } + const projects = stringArray(object.value.projects, "projects", 50, 100); + if (!projects.ok) return projects; + + return { + ok: true, + value: { + expectedVersion: expectedVersion.value, + enabled: object.value.enabled, + intervalMinutes: intervalMinutes.value, + quietStartMinute: quietStartMinute.value, + quietEndMinute: quietEndMinute.value, + cooldownMinutes: cooldownMinutes.value, + dailyLimit: dailyLimit.value, + defaultSnoozeMinutes: defaultSnoozeMinutes.value, + statuses: statuses.value as FlowSettingsUpdate["statuses"], + tags: normalizedTags, + projects: projects.value, + }, + }; +} + +export function validateOutcome(body: unknown): Validation { + const object = record(body); + if (!object.ok) return object; + if ( + !hasExactKeys(object.value, [ + "expectedDeliveryVersion", + "expectedInspirationVersion", + "outcome", + "snoozeMinutes", + ]) + ) { + return { ok: false, error: "outcome body contains unknown fields" }; + } + const expectedDeliveryVersion = integer( + object.value.expectedDeliveryVersion, + "expectedDeliveryVersion", + 1, + 2_147_483_647 + ); + if (!expectedDeliveryVersion.ok) return expectedDeliveryVersion; + const expectedInspirationVersion = integer( + object.value.expectedInspirationVersion, + "expectedInspirationVersion", + 1, + 2_147_483_647 + ); + if (!expectedInspirationVersion.ok) return expectedInspirationVersion; + if (!FLOW_OUTCOMES.has(object.value.outcome as FlowOutcome)) { + return { + ok: false, + error: "outcome must be viewed, continued, kept, later, or archived", + }; + } + const outcome = object.value.outcome as FlowOutcome; + if (outcome !== "later" && object.value.snoozeMinutes !== undefined) { + return { ok: false, error: "snoozeMinutes is only valid for later" }; + } + let snoozeMinutes: number | undefined; + if (object.value.snoozeMinutes !== undefined) { + const validated = integer( + object.value.snoozeMinutes, + "snoozeMinutes", + 1, + 525_600 + ); + if (!validated.ok) return validated; + snoozeMinutes = validated.value; + } + return { + ok: true, + value: { + expectedDeliveryVersion: expectedDeliveryVersion.value, + expectedInspirationVersion: expectedInspirationVersion.value, + outcome, + ...(snoozeMinutes === undefined ? {} : { snoozeMinutes }), + }, + }; +} + +function flowError(error: unknown): PluginHttpResponse { + if (!(error instanceof FlowStoreError)) throw error; + return response(error.statusCode, { + error: error.message, + code: error.code, + ...(error.currentDeliveryVersion === undefined + ? {} + : { currentDeliveryVersion: error.currentDeliveryVersion }), + ...(error.currentInspirationVersion === undefined + ? {} + : { currentInspirationVersion: error.currentInspirationVersion }), + }); +} + +export function createFlowRoutes(service: () => FlowService): PluginRoute[] { + return [ + { + method: "GET", + path: "/api/plugins/inspiration/flow/settings", + async handler() { + return service().getSettings(); + }, + }, + { + method: "PATCH", + path: "/api/plugins/inspiration/flow/settings", + async handler(request) { + const validated = validateSettingsUpdate(request.body); + if (!validated.ok) return response(400, { error: validated.error }); + const updated = await service().updateSettings(validated.value); + if (updated) return updated; + const current = await service().getSettings(); + return response(409, { + error: "Flow settings version conflict", + code: "VERSION_CONFLICT", + currentVersion: current.version, + }); + }, + }, + { + method: "POST", + path: "/api/plugins/inspiration/flow/next", + async handler(request: PluginHttpRequest, signal) { + const body = request.body == null ? {} : request.body; + const object = record(body); + if (!object.ok) return response(400, { error: object.error }); + if (!hasExactKeys(object.value, ["idempotencyKey"])) { + return response(400, { error: "next body contains unknown fields" }); + } + const idempotencyKey = object.value.idempotencyKey; + if ( + idempotencyKey !== undefined && + (typeof idempotencyKey !== "string" || + idempotencyKey.trim().length === 0 || + idempotencyKey.length > 200) + ) { + return response(400, { + error: "idempotencyKey must be a non-empty string up to 200 characters", + }); + } + try { + const result = await service().nextManual( + typeof idempotencyKey === "string" ? idempotencyKey : undefined, + signal + ); + return { + candidate: result.candidate, + explanation: result.explanation, + }; + } catch (error) { + return flowError(error); + } + }, + }, + { + method: "GET", + path: "/api/plugins/inspiration/flow/deliveries", + async handler(request) { + const query = request.query == null ? {} : request.query; + if (!query || typeof query !== "object" || Array.isArray(query)) { + return response(400, { error: "query must be an object" }); + } + const value = query as Record; + if (!hasExactKeys(value, ["limit", "before"])) { + return response(400, { error: "deliveries query contains unknown fields" }); + } + const rawLimit = value.limit === undefined ? 50 : Number(value.limit); + const limit = integer(rawLimit, "limit", 1, 100); + if (!limit.ok) return response(400, { error: limit.error }); + let before: Date | undefined; + if (value.before !== undefined) { + if (typeof value.before !== "string") { + return response(400, { error: "before must be an ISO 8601 timestamp" }); + } + before = new Date(value.before); + if (!value.before.includes("T") || Number.isNaN(before.getTime())) { + return response(400, { error: "before must be an ISO 8601 timestamp" }); + } + } + return { deliveries: await service().listDeliveries(limit.value, before) }; + }, + }, + { + method: "POST", + path: "/api/plugins/inspiration/flow/deliveries/:id/outcome", + async handler(request) { + if (!DELIVERY_ID_RE.test(request.params.id)) { + return response(400, { error: "delivery id is invalid" }); + } + const validated = validateOutcome(request.body); + if (!validated.ok) return response(400, { error: validated.error }); + try { + return await service().applyOutcome( + request.params.id, + validated.value + ); + } catch (error) { + return flowError(error); + } + }, + }, + ]; +} diff --git a/plugins/inspiration/src/flow-store.ts b/plugins/inspiration/src/flow-store.ts new file mode 100644 index 0000000..e7ad3bd --- /dev/null +++ b/plugins/inspiration/src/flow-store.ts @@ -0,0 +1,654 @@ +import { nanoid } from "nanoid"; +import postgres from "postgres"; +import { + isQuietMinute, + minuteOfLocalDay, + selectFlowCandidate, +} from "./selector.js"; +import type { + DailyInspirationSummary, + FlowCandidate, + FlowDelivery, + FlowOutcome, + FlowSettings, + FlowSettingsUpdate, + FlowSource, + Inspiration, + InspirationStatus, +} from "./types.js"; + +type SettingsRow = { + id: "default"; + version: number; + enabled: boolean; + interval_minutes: number; + quiet_start_minute: number; + quiet_end_minute: number; + cooldown_minutes: number; + daily_limit: number; + default_snooze_minutes: number; + statuses: string[]; + tags: string[]; + projects: string[]; + updated_at: Date | string; +}; + +type InspirationRow = { + id: string; + version: number; + content: string; + tags: string[]; + project: string | null; + status: InspirationStatus; + created_at: Date | string; + updated_at: Date | string; + archived_at: Date | string | null; + last_surfaced_at: Date | string | null; +}; + +type DeliveryRow = { + id: string; + version: number; + attempts: number; + inspiration_id: string; + source: FlowSource; + dedupe_key: string; + status: FlowDelivery["status"]; + outcome: FlowOutcome | null; + surfaced_at: Date | string; + notified_at: Date | string | null; + snoozed_until: Date | string | null; + outcome_at: Date | string | null; + notification_channel: string | null; + error: string | null; + created_at: Date | string; + updated_at: Date | string; +}; + +export type FlowStoreErrorCode = + | "NOT_FOUND" + | "VERSION_CONFLICT" + | "INVALID_STATE"; + +export class FlowStoreError extends Error { + constructor( + public readonly code: FlowStoreErrorCode, + message: string, + public readonly statusCode: 404 | 409, + public readonly currentDeliveryVersion?: number, + public readonly currentInspirationVersion?: number + ) { + super(message); + this.name = "FlowStoreError"; + } +} + +export interface FlowReserveResult { + candidate: FlowCandidate | null; + explanation: string[]; + shouldNotify: boolean; +} + +export interface FlowOutcomeResult { + delivery: FlowDelivery; + inspiration: Inspiration; +} + +function date(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +function nullableDate(value: Date | string | null): Date | null { + return value === null ? null : date(value); +} + +function mapSettings(row: SettingsRow): FlowSettings { + return { + id: "default", + version: row.version, + enabled: row.enabled, + intervalMinutes: row.interval_minutes, + quietStartMinute: row.quiet_start_minute, + quietEndMinute: row.quiet_end_minute, + cooldownMinutes: row.cooldown_minutes, + dailyLimit: row.daily_limit, + defaultSnoozeMinutes: row.default_snooze_minutes, + statuses: row.statuses as FlowSettings["statuses"], + tags: row.tags, + projects: row.projects, + updatedAt: date(row.updated_at), + }; +} + +function mapInspiration(row: InspirationRow): Inspiration { + return { + id: row.id, + version: row.version, + content: row.content, + tags: row.tags, + project: row.project, + status: row.status, + createdAt: date(row.created_at), + updatedAt: date(row.updated_at), + archivedAt: nullableDate(row.archived_at), + lastSurfacedAt: nullableDate(row.last_surfaced_at), + }; +} + +function mapDelivery(row: DeliveryRow): FlowDelivery { + return { + id: row.id, + version: row.version, + attempts: row.attempts, + inspirationId: row.inspiration_id, + source: row.source, + dedupeKey: row.dedupe_key, + status: row.status, + outcome: row.outcome, + surfacedAt: date(row.surfaced_at), + notifiedAt: nullableDate(row.notified_at), + snoozedUntil: nullableDate(row.snoozed_until), + outcomeAt: nullableDate(row.outcome_at), + notificationChannel: row.notification_channel, + error: row.error, + createdAt: date(row.created_at), + updatedAt: date(row.updated_at), + }; +} + +export const FLOW_RESERVATION_LEASE_MS = 30_000; + +function startOfLocalDay(value: Date): Date { + return new Date( + value.getFullYear(), + value.getMonth(), + value.getDate(), + 0, + 0, + 0, + 0 + ); +} + +function dateBounds(localDate: string): { start: Date; end: Date } { + const [year, month, day] = localDate.split("-").map(Number); + const start = new Date(year!, month! - 1, day!, 0, 0, 0, 0); + const end = new Date(year!, month! - 1, day! + 1, 0, 0, 0, 0); + return { start, end }; +} + +export class FlowStore { + private readonly sql; + + constructor(databaseUrl: string) { + this.sql = postgres(databaseUrl); + } + + async close(): Promise { + await this.sql.end(); + } + + async getSettings(): Promise { + const rows = await this.sql` + SELECT * FROM inspiration_flow_settings WHERE id = 'default' + `; + const row = rows[0]; + if (!row) throw new Error("inspiration Flow settings are unavailable"); + return mapSettings(row); + } + + async updateSettings(input: FlowSettingsUpdate): Promise { + const rows = await this.sql` + UPDATE inspiration_flow_settings + SET enabled = ${input.enabled}, + interval_minutes = ${input.intervalMinutes}, + quiet_start_minute = ${input.quietStartMinute}, + quiet_end_minute = ${input.quietEndMinute}, + cooldown_minutes = ${input.cooldownMinutes}, + daily_limit = ${input.dailyLimit}, + default_snooze_minutes = ${input.defaultSnoozeMinutes}, + statuses = ${input.statuses}, + tags = ${input.tags}, + projects = ${input.projects}, + version = version + 1, + updated_at = NOW() + WHERE id = 'default' AND version = ${input.expectedVersion} + RETURNING * + `; + return rows[0] ? mapSettings(rows[0]) : null; + } + + async reserveNext( + source: FlowSource, + dedupeKey: string, + now = new Date(), + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted(); + return this.sql.begin(async (transaction) => { + // The advisory lock turns a concurrent unique-key race into a normal + // idempotent lookup, without leaving the losing transaction aborted. + await transaction` + SELECT pg_advisory_xact_lock(hashtextextended(${dedupeKey}, 0)) + `; + signal?.throwIfAborted(); + + const duplicateRows = await transaction` + SELECT * FROM inspiration_flow_deliveries WHERE dedupe_key = ${dedupeKey} + `; + signal?.throwIfAborted(); + + const resultForDelivery = async ( + delivery: DeliveryRow, + explanation: string[], + shouldNotify: boolean + ): Promise => { + const inspirationRows = await transaction` + SELECT * FROM inspirations + WHERE id = ${delivery.inspiration_id} + `; + signal?.throwIfAborted(); + const inspiration = inspirationRows[0]; + if (!inspiration) { + throw new Error("Flow delivery references a missing inspiration"); + } + return { + candidate: { + inspiration: mapInspiration(inspiration), + delivery: mapDelivery(delivery), + explanation, + duplicate: true, + }, + explanation, + shouldNotify, + }; + }; + + const duplicate = duplicateRows[0]; + if (duplicate && duplicate.status !== "reserved") { + return resultForDelivery( + duplicate, + ["dedupe:existing-delivery"], + false + ); + } + + const settingsRows = await transaction` + SELECT * FROM inspiration_flow_settings WHERE id = 'default' FOR UPDATE + `; + signal?.throwIfAborted(); + const settingsRow = settingsRows[0]; + if (!settingsRow) throw new Error("inspiration Flow settings are unavailable"); + const settings = mapSettings(settingsRow); + + let pending = duplicate; + if (!pending && source === "scheduled") { + const pendingRows = await transaction` + SELECT * FROM inspiration_flow_deliveries + WHERE source = 'scheduled' AND status = 'reserved' + ORDER BY created_at, id + LIMIT 1 + FOR UPDATE + `; + signal?.throwIfAborted(); + pending = pendingRows[0]; + } + + if (pending) { + if (source === "scheduled") { + const gateReasons = [ + ...(!settings.enabled ? ["policy:disabled"] : []), + ...(isQuietMinute( + minuteOfLocalDay(now), + settings.quietStartMinute, + settings.quietEndMinute + ) ? ["policy:quiet-hours"] : []), + ]; + if (gateReasons.length > 0) { + return resultForDelivery(pending, gateReasons, false); + } + } + + const retryCutoff = new Date(now.getTime() - FLOW_RESERVATION_LEASE_MS); + if (date(pending.updated_at).getTime() > retryCutoff.getTime()) { + return resultForDelivery( + pending, + ["dedupe:delivery-in-flight"], + false + ); + } + + signal?.throwIfAborted(); + const claimedRows = await transaction` + UPDATE inspiration_flow_deliveries + SET attempts = attempts + 1, + version = version + 1, + updated_at = ${now} + WHERE id = ${pending.id} + AND version = ${pending.version} + AND status = 'reserved' + RETURNING * + `; + signal?.throwIfAborted(); + const claimed = claimedRows[0]; + if (!claimed) { + throw new FlowStoreError( + "VERSION_CONFLICT", + `delivery ${pending.id} changed during recovery`, + 409, + pending.version + ); + } + return resultForDelivery( + claimed, + ["recovery:pending-delivery"], + true + ); + } + + // Lock the complete local candidate set. This personal-data plugin is + // intentionally small, and the lock makes independent manual/scheduled + // reservations share one serial, deterministic selector snapshot. + const inspirationRows = await transaction` + SELECT * FROM inspirations + ORDER BY last_surfaced_at NULLS FIRST, created_at, id + FOR UPDATE + `; + signal?.throwIfAborted(); + const snoozeRows = await transaction<{ + inspiration_id: string; + snoozed_until: Date | string | null; + }[]>` + SELECT inspiration_id, MAX(snoozed_until) AS snoozed_until + FROM inspiration_flow_deliveries + WHERE snoozed_until IS NOT NULL + GROUP BY inspiration_id + `; + signal?.throwIfAborted(); + const snoozes = new Map( + snoozeRows.map((row) => [ + row.inspiration_id, + nullableDate(row.snoozed_until), + ]) + ); + const dayStart = startOfLocalDay(now); + const dailyRows = await transaction<{ count: number }[]>` + SELECT COUNT(*)::int AS count + FROM inspiration_flow_deliveries + WHERE surfaced_at >= ${dayStart} AND surfaced_at < ${new Date( + dayStart.getFullYear(), + dayStart.getMonth(), + dayStart.getDate() + 1 + )} + `; + signal?.throwIfAborted(); + + const selection = selectFlowCandidate({ + candidates: inspirationRows.map((row) => ({ + inspiration: mapInspiration(row), + snoozedUntil: snoozes.get(row.id) ?? null, + })), + settings, + source, + now, + surfacedToday: dailyRows[0]?.count ?? 0, + }); + if (!selection.selected) { + return { + candidate: null, + explanation: selection.explanation, + shouldNotify: false, + }; + } + + const current = selection.selected.inspiration; + signal?.throwIfAborted(); + const updatedRows = await transaction` + UPDATE inspirations + SET last_surfaced_at = ${now}, + version = version + 1, + updated_at = ${now} + WHERE id = ${current.id} AND version = ${current.version} + RETURNING * + `; + signal?.throwIfAborted(); + const updated = updatedRows[0]; + if (!updated) { + throw new FlowStoreError( + "VERSION_CONFLICT", + "inspiration version changed during Flow reservation", + 409, + undefined, + current.version + ); + } + + const deliveryRows = await transaction` + INSERT INTO inspiration_flow_deliveries ( + id, inspiration_id, source, dedupe_key, status, surfaced_at, + created_at, updated_at + ) VALUES ( + ${nanoid(12)}, ${current.id}, ${source}, ${dedupeKey}, 'reserved', + ${now}, ${now}, ${now} + ) + RETURNING * + `; + signal?.throwIfAborted(); + const delivery = deliveryRows[0]; + if (!delivery) throw new Error("Flow delivery reservation failed"); + return { + candidate: { + inspiration: mapInspiration(updated), + delivery: mapDelivery(delivery), + explanation: selection.explanation, + duplicate: false, + }, + explanation: selection.explanation, + shouldNotify: true, + }; + }); + } + + async finalizeNotification( + deliveryId: string, + expectedVersion: number, + result: + | { delivered: true; channel: string | null; at: Date } + | { delivered: false; error: string; at: Date } + ): Promise { + const rows = result.delivered + ? await this.sql` + UPDATE inspiration_flow_deliveries + SET status = 'sent', notified_at = ${result.at}, + notification_channel = ${result.channel}, error = NULL, + version = version + 1, updated_at = ${result.at} + WHERE id = ${deliveryId} AND version = ${expectedVersion} + AND status = 'reserved' + RETURNING * + ` + : await this.sql` + UPDATE inspiration_flow_deliveries + SET status = 'failed', error = ${result.error}, + version = version + 1, updated_at = ${result.at} + WHERE id = ${deliveryId} AND version = ${expectedVersion} + AND status = 'reserved' + RETURNING * + `; + if (rows[0]) return mapDelivery(rows[0]); + + const currentRows = await this.sql` + SELECT * FROM inspiration_flow_deliveries WHERE id = ${deliveryId} + `; + const current = currentRows[0]; + if (!current) { + throw new FlowStoreError("NOT_FOUND", `delivery ${deliveryId} not found`, 404); + } + throw new FlowStoreError( + "VERSION_CONFLICT", + `delivery ${deliveryId} changed before notification finalization`, + 409, + current.version + ); + } + + async listDeliveries( + limit = 50, + before?: Date + ): Promise { + const rows = before + ? await this.sql` + SELECT * FROM inspiration_flow_deliveries + WHERE surfaced_at < ${before} + ORDER BY surfaced_at DESC, id DESC + LIMIT ${limit} + ` + : await this.sql` + SELECT * FROM inspiration_flow_deliveries + ORDER BY surfaced_at DESC, id DESC + LIMIT ${limit} + `; + return rows.map(mapDelivery); + } + + async applyOutcome( + deliveryId: string, + expectedDeliveryVersion: number, + expectedInspirationVersion: number, + outcome: FlowOutcome, + snoozedUntil: Date | null, + now = new Date() + ): Promise { + return this.sql.begin(async (transaction) => { + const deliveryRows = await transaction` + SELECT * FROM inspiration_flow_deliveries + WHERE id = ${deliveryId} + FOR UPDATE + `; + const delivery = deliveryRows[0]; + if (!delivery) { + throw new FlowStoreError( + "NOT_FOUND", + `delivery ${deliveryId} not found`, + 404 + ); + } + const inspirationRows = await transaction` + SELECT * FROM inspirations + WHERE id = ${delivery.inspiration_id} + FOR UPDATE + `; + const inspiration = inspirationRows[0]; + if (!inspiration) { + throw new FlowStoreError( + "NOT_FOUND", + `inspiration ${delivery.inspiration_id} not found`, + 404 + ); + } + if ( + delivery.version !== expectedDeliveryVersion || + inspiration.version !== expectedInspirationVersion + ) { + throw new FlowStoreError( + "VERSION_CONFLICT", + "Flow outcome version conflict", + 409, + delivery.version, + inspiration.version + ); + } + if (delivery.status !== "sent") { + throw new FlowStoreError( + "INVALID_STATE", + `delivery ${deliveryId} is ${delivery.status}, not sent`, + 409, + delivery.version, + inspiration.version + ); + } + + let finalInspiration = inspiration; + if (outcome === "kept" || outcome === "archived") { + const lifecycleRows = outcome === "kept" + ? await transaction` + UPDATE inspirations + SET status = 'kept', archived_at = NULL, + version = version + 1, updated_at = ${now} + WHERE id = ${inspiration.id} + AND version = ${expectedInspirationVersion} + RETURNING * + ` + : await transaction` + UPDATE inspirations + SET status = 'archived', archived_at = ${now}, + version = version + 1, updated_at = ${now} + WHERE id = ${inspiration.id} + AND version = ${expectedInspirationVersion} + RETURNING * + `; + if (!lifecycleRows[0]) { + throw new FlowStoreError( + "VERSION_CONFLICT", + "inspiration version conflict while recording Flow outcome", + 409, + delivery.version, + inspiration.version + ); + } + finalInspiration = lifecycleRows[0]; + } + + const updatedDeliveryRows = await transaction` + UPDATE inspiration_flow_deliveries + SET status = 'acted', outcome = ${outcome}, outcome_at = ${now}, + snoozed_until = ${outcome === "later" ? snoozedUntil : null}, + version = version + 1, updated_at = ${now} + WHERE id = ${deliveryId} + AND version = ${expectedDeliveryVersion} + AND status = 'sent' + RETURNING * + `; + if (!updatedDeliveryRows[0]) { + throw new FlowStoreError( + "VERSION_CONFLICT", + "delivery version conflict while recording Flow outcome", + 409, + delivery.version, + inspiration.version + ); + } + return { + delivery: mapDelivery(updatedDeliveryRows[0]), + inspiration: mapInspiration(finalInspiration), + }; + }); + } + + async getDailySummary(localDate: string): Promise { + const { start, end } = dateBounds(localDate); + const [capturedRows, surfacedRows, outcomeRows] = await Promise.all([ + this.sql<{ count: number }[]>` + SELECT COUNT(*)::int AS count FROM inspirations + WHERE created_at >= ${start} AND created_at < ${end} + `, + this.sql<{ count: number }[]>` + SELECT COUNT(*)::int AS count FROM inspiration_flow_deliveries + WHERE surfaced_at >= ${start} AND surfaced_at < ${end} + `, + this.sql<{ outcome: FlowOutcome; count: number }[]>` + SELECT outcome, COUNT(*)::int AS count + FROM inspiration_flow_deliveries + WHERE outcome_at >= ${start} AND outcome_at < ${end} + AND outcome IS NOT NULL + GROUP BY outcome + `, + ]); + return { + captured: capturedRows[0]?.count ?? 0, + surfaced: surfacedRows[0]?.count ?? 0, + outcomes: Object.fromEntries( + outcomeRows.map((row) => [row.outcome, row.count]) + ), + }; + } +} diff --git a/plugins/inspiration/src/flow.ts b/plugins/inspiration/src/flow.ts new file mode 100644 index 0000000..c8e9d3a --- /dev/null +++ b/plugins/inspiration/src/flow.ts @@ -0,0 +1,211 @@ +import { nanoid } from "nanoid"; +import type { PluginJob } from "@echolog/plugin-sdk"; +import type { + FlowOutcomeResult, + FlowReserveResult, +} from "./flow-store.js"; +import { + sendFlowNotification, + type NotificationsSendProvider, +} from "./notifications.js"; +import type { + DailyInspirationSummary, + FlowDelivery, + FlowOutcomeInput, + FlowSettings, + FlowSettingsUpdate, +} from "./types.js"; + +export const FLOW_JOB_POLL_MS = 60_000; +export const FLOW_JOB_TIMEOUT_MS = 30_000; + +export function scheduledFlowDedupeKey( + now: Date, + intervalMinutes: number +): string { + const intervalMs = intervalMinutes * 60_000; + return `scheduled:${intervalMinutes}:${Math.floor(now.getTime() / intervalMs)}`; +} + +function manualFlowDedupeKey(idempotencyKey?: string): string { + return `manual:${idempotencyKey ?? nanoid(20)}`; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +export interface FlowPersistence { + getSettings(): Promise; + updateSettings(input: FlowSettingsUpdate): Promise; + reserveNext( + source: "manual" | "scheduled", + dedupeKey: string, + now?: Date, + signal?: AbortSignal + ): Promise; + finalizeNotification( + deliveryId: string, + expectedVersion: number, + result: + | { delivered: true; channel: string | null; at: Date } + | { delivered: false; error: string; at: Date } + ): Promise; + listDeliveries(limit?: number, before?: Date): Promise; + applyOutcome( + deliveryId: string, + expectedDeliveryVersion: number, + expectedInspirationVersion: number, + outcome: FlowOutcomeInput["outcome"], + snoozedUntil: Date | null, + now?: Date + ): Promise; + getDailySummary(date: string): Promise; +} + +export class FlowService { + constructor( + private readonly store: FlowPersistence, + private readonly notifications: NotificationsSendProvider, + private readonly clock: () => Date = () => new Date() + ) {} + + getSettings(): Promise { + return this.store.getSettings(); + } + + updateSettings(input: FlowSettingsUpdate): Promise { + return this.store.updateSettings(input); + } + + async nextManual( + idempotencyKey?: string, + signal?: AbortSignal + ): Promise { + return this.deliver( + "manual", + manualFlowDedupeKey(idempotencyKey), + this.clock(), + signal + ); + } + + async runScheduled(signal: AbortSignal): Promise { + signal.throwIfAborted(); + const now = this.clock(); + const settings = await this.store.getSettings(); + return this.deliver( + "scheduled", + scheduledFlowDedupeKey(now, settings.intervalMinutes), + now, + signal + ); + } + + private async deliver( + source: "manual" | "scheduled", + dedupeKey: string, + now: Date, + signal?: AbortSignal + ): Promise { + const reserved = await this.store.reserveNext( + source, + dedupeKey, + now, + signal + ); + const candidate = reserved.candidate; + if (!candidate) return reserved; + + // The store atomically decides whether this caller owns the notification + // attempt. Existing/freshly in-flight duplicates remain observable without + // causing another send; stale reservations are claimed across restarts. + if (!reserved.shouldNotify) return reserved; + + let notification; + try { + signal?.throwIfAborted(); + notification = await sendFlowNotification( + this.notifications, + candidate, + signal + ); + signal?.throwIfAborted(); + } catch (error) { + // Preserve a reserved row on cancellation. The Host's rejecting timeout + // releases its non-reentry guard, and the next identical bucket can + // safely resume with the notification dedupe key after restart/timeout. + if (signal?.aborted || isAbortError(error)) throw error; + candidate.delivery = await this.store.finalizeNotification( + candidate.delivery.id, + candidate.delivery.version, + { + delivered: false, + // Do not persist exception text: provider errors may echo request + // bodies. The ledger records a stable diagnostic without retaining + // notification content, prompts, or replies. + error: "notifications.send failed", + at: this.clock(), + } + ); + return reserved; + } + + candidate.delivery = await this.store.finalizeNotification( + candidate.delivery.id, + candidate.delivery.version, + notification.delivered + ? { + delivered: true, + channel: notification.channel ?? null, + at: this.clock(), + } + : { + delivered: false, + error: "notifications.send reported an undelivered notification", + at: this.clock(), + } + ); + return reserved; + } + + listDeliveries(limit?: number, before?: Date) { + return this.store.listDeliveries(limit, before); + } + + async applyOutcome( + deliveryId: string, + input: FlowOutcomeInput + ): Promise { + const now = this.clock(); + let snoozedUntil: Date | null = null; + if (input.outcome === "later") { + const settings = await this.store.getSettings(); + const minutes = input.snoozeMinutes ?? settings.defaultSnoozeMinutes; + snoozedUntil = new Date(now.getTime() + minutes * 60_000); + } + return this.store.applyOutcome( + deliveryId, + input.expectedDeliveryVersion, + input.expectedInspirationVersion, + input.outcome, + snoozedUntil, + now + ); + } + + getDailySummary(date: string): Promise { + return this.store.getDailySummary(date); + } +} + +export function createFlowJob(service: FlowService): PluginJob { + return { + id: "inspiration-flow", + intervalMs: FLOW_JOB_POLL_MS, + timeoutMs: FLOW_JOB_TIMEOUT_MS, + async run(signal) { + await service.runScheduled(signal); + }, + }; +} diff --git a/plugins/inspiration/src/index.ts b/plugins/inspiration/src/index.ts new file mode 100644 index 0000000..019fac3 --- /dev/null +++ b/plugins/inspiration/src/index.ts @@ -0,0 +1,85 @@ +import type { + PluginDefinition, + PluginManifest, +} from "@echolog/plugin-sdk"; +import manifestJson from "../echolog.plugin.json"; +import { renderInspirationDailySummary } from "./cli.js"; +import { createFlowRoutes } from "./flow-routes.js"; +import { FlowStore } from "./flow-store.js"; +import { createFlowJob, FlowService } from "./flow.js"; +import { migrations } from "./migrations.js"; +import { notificationsSendProvider } from "./notifications.js"; +import { createInspirationRoutes } from "./routes.js"; +import { InspirationStore } from "./store.js"; + +const manifest = manifestJson as PluginManifest; + +let inspirationStore: InspirationStore | null = null; +let flowStore: FlowStore | null = null; +let flowService: FlowService | null = null; + +function requireInspirationStore(): InspirationStore { + if (!inspirationStore) throw new Error("inspiration store is not initialized"); + return inspirationStore; +} + +function requireFlowService(): FlowService { + if (!flowService) throw new Error("inspiration Flow is not initialized"); + return flowService; +} + +export const inspirationPlugin: PluginDefinition = { + manifest, + routes: [ + ...createInspirationRoutes(requireInspirationStore), + ...createFlowRoutes(requireFlowService), + ], + defaultEnabled: true, + defaultConfig: {}, + validateConfig(config) { + return Object.keys(config).length === 0 + ? [] + : ["inspiration configuration does not accept fields in v1"]; + }, + migrations, + register(context) { + const databaseUrl = context.service("database.url"); + inspirationStore = new InspirationStore(databaseUrl); + flowStore = new FlowStore(databaseUrl); + flowService = new FlowService( + flowStore, + notificationsSendProvider(context) + ); + + context.registerJob(createFlowJob(flowService)); + context.registerReportSection({ + id: "daily-inspiration", + title: "灵感", + order: 250, + async render(date) { + return renderInspirationDailySummary( + await requireFlowService().getDailySummary(date) + ); + }, + }); + }, + start(context) { + context.logger.info( + { notificationService: "notifications.send" }, + "Inspiration plugin started" + ); + }, + async stop() { + const stores = [inspirationStore, flowStore].filter( + (store): store is InspirationStore | FlowStore => store !== null + ); + inspirationStore = null; + flowStore = null; + flowService = null; + await Promise.all(stores.map((store) => store.close())); + }, +}; + +export default inspirationPlugin; +export { migrations } from "./migrations.js"; +export type { NotificationsSendService } from "./notifications.js"; diff --git a/plugins/inspiration/src/migrations.ts b/plugins/inspiration/src/migrations.ts new file mode 100644 index 0000000..aa0ed01 --- /dev/null +++ b/plugins/inspiration/src/migrations.ts @@ -0,0 +1,134 @@ +import type { PluginMigration } from "@echolog/plugin-sdk"; + +export const migrations: PluginMigration[] = [ + { + name: "001_inspirations", + sql: ` + CREATE TABLE IF NOT EXISTS inspirations ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL DEFAULT 1, + content TEXT NOT NULL, + tags TEXT[] NOT NULL DEFAULT '{}'::text[], + project TEXT, + status TEXT NOT NULL DEFAULT 'inbox', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + archived_at TIMESTAMPTZ, + last_surfaced_at TIMESTAMPTZ, + CONSTRAINT inspirations_version_check CHECK (version >= 1), + CONSTRAINT inspirations_content_check + CHECK (char_length(trim(content)) BETWEEN 1 AND 10000), + CONSTRAINT inspirations_status_check + CHECK (status IN ('inbox', 'kept', 'archived')), + CONSTRAINT inspirations_archive_check + CHECK ((status = 'archived') = (archived_at IS NOT NULL)) + ); + CREATE INDEX IF NOT EXISTS idx_inspirations_flow_selection + ON inspirations( + status, + last_surfaced_at ASC NULLS FIRST, + created_at ASC, + id ASC + ); + CREATE INDEX IF NOT EXISTS idx_inspirations_history + ON inspirations(created_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_inspirations_project_status + ON inspirations(project, status); + CREATE INDEX IF NOT EXISTS idx_inspirations_tags + ON inspirations USING GIN(tags); + `, + }, + { + name: "002_inspiration_flow_settings", + sql: ` + CREATE TABLE IF NOT EXISTS inspiration_flow_settings ( + id TEXT PRIMARY KEY DEFAULT 'default', + version INTEGER NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + interval_minutes INTEGER NOT NULL DEFAULT 240, + quiet_start_minute INTEGER NOT NULL DEFAULT 1320, + quiet_end_minute INTEGER NOT NULL DEFAULT 480, + cooldown_minutes INTEGER NOT NULL DEFAULT 1440, + daily_limit INTEGER NOT NULL DEFAULT 3, + default_snooze_minutes INTEGER NOT NULL DEFAULT 1440, + statuses TEXT[] NOT NULL DEFAULT ARRAY['inbox', 'kept']::text[], + tags TEXT[] NOT NULL DEFAULT '{}'::text[], + projects TEXT[] NOT NULL DEFAULT '{}'::text[], + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT inspiration_flow_settings_singleton CHECK (id = 'default'), + CONSTRAINT inspiration_flow_settings_version_check CHECK (version >= 1), + CONSTRAINT inspiration_flow_settings_interval_check + CHECK (interval_minutes BETWEEN 1 AND 10080), + CONSTRAINT inspiration_flow_settings_quiet_start_check + CHECK (quiet_start_minute BETWEEN 0 AND 1439), + CONSTRAINT inspiration_flow_settings_quiet_end_check + CHECK (quiet_end_minute BETWEEN 0 AND 1439), + CONSTRAINT inspiration_flow_settings_cooldown_check + CHECK (cooldown_minutes BETWEEN 0 AND 525600), + CONSTRAINT inspiration_flow_settings_daily_limit_check + CHECK (daily_limit BETWEEN 1 AND 1000), + CONSTRAINT inspiration_flow_settings_snooze_check + CHECK (default_snooze_minutes BETWEEN 1 AND 525600), + CONSTRAINT inspiration_flow_settings_statuses_check + CHECK (statuses <@ ARRAY['inbox', 'kept']::text[]) + ); + INSERT INTO inspiration_flow_settings (id) + VALUES ('default') + ON CONFLICT (id) DO NOTHING; + `, + }, + { + name: "003_inspiration_flow_deliveries", + sql: ` + CREATE TABLE IF NOT EXISTS inspiration_flow_deliveries ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL DEFAULT 1, + inspiration_id TEXT NOT NULL REFERENCES inspirations(id) + ON DELETE RESTRICT, + source TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + status TEXT NOT NULL, + outcome TEXT, + surfaced_at TIMESTAMPTZ NOT NULL, + notified_at TIMESTAMPTZ, + snoozed_until TIMESTAMPTZ, + outcome_at TIMESTAMPTZ, + notification_channel TEXT, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT inspiration_flow_deliveries_version_check CHECK (version >= 1), + CONSTRAINT inspiration_flow_deliveries_source_check + CHECK (source IN ('manual', 'scheduled')), + CONSTRAINT inspiration_flow_deliveries_status_check + CHECK (status IN ('reserved', 'sent', 'failed', 'acted')), + CONSTRAINT inspiration_flow_deliveries_outcome_check + CHECK (outcome IS NULL OR outcome IN ('viewed', 'continued', 'kept', 'later', 'archived')), + CONSTRAINT inspiration_flow_deliveries_acted_check + CHECK ((status = 'acted') = (outcome IS NOT NULL AND outcome_at IS NOT NULL)) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_inspiration_flow_deliveries_dedupe_key + ON inspiration_flow_deliveries(dedupe_key); + CREATE INDEX IF NOT EXISTS idx_inspiration_flow_deliveries_inspiration_surfaced + ON inspiration_flow_deliveries(inspiration_id, surfaced_at DESC); + CREATE INDEX IF NOT EXISTS idx_inspiration_flow_deliveries_status_created + ON inspiration_flow_deliveries(status, created_at); + CREATE INDEX IF NOT EXISTS idx_inspiration_flow_deliveries_surfaced_at + ON inspiration_flow_deliveries(surfaced_at); + CREATE INDEX IF NOT EXISTS idx_inspiration_flow_deliveries_snoozed_until + ON inspiration_flow_deliveries(snoozed_until); + `, + }, + { + name: "004_inspiration_flow_delivery_attempts", + sql: ` + ALTER TABLE inspiration_flow_deliveries + ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 1; + ALTER TABLE inspiration_flow_deliveries + DROP CONSTRAINT IF EXISTS inspiration_flow_deliveries_attempts_check; + ALTER TABLE inspiration_flow_deliveries + ADD CONSTRAINT inspiration_flow_deliveries_attempts_check + CHECK (attempts >= 1); + `, + }, +]; diff --git a/plugins/inspiration/src/notifications.ts b/plugins/inspiration/src/notifications.ts new file mode 100644 index 0000000..3aeb4e7 --- /dev/null +++ b/plugins/inspiration/src/notifications.ts @@ -0,0 +1,54 @@ +import type { PluginContext } from "@echolog/plugin-sdk"; +import type { FlowCandidate } from "./types.js"; + +export interface NotificationsSendInput { + title: string; + body: string; + dedupeKey: string; + data: { + pluginId: "inspiration"; + inspirationId: string; + deliveryId: string; + }; +} + +export interface NotificationsSendResult { + delivered: boolean; + channel?: string; +} + +export interface NotificationsSendService { + send( + input: NotificationsSendInput, + signal?: AbortSignal + ): Promise; +} + +export type NotificationsSendProvider = () => NotificationsSendService; + +export function notificationsSendProvider( + context: PluginContext +): NotificationsSendProvider { + // Service resolution must remain lazy: capture and organization continue to + // work when the independently shipped notification capability is absent. + return () => + context.service("notifications.send"); +} + +export function sendFlowNotification( + provider: NotificationsSendProvider, + candidate: FlowCandidate, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted(); + return provider().send({ + title: "Inspiration", + body: candidate.inspiration.content, + dedupeKey: candidate.delivery.dedupeKey, + data: { + pluginId: "inspiration", + inspirationId: candidate.inspiration.id, + deliveryId: candidate.delivery.id, + }, + }, signal); +} diff --git a/plugins/inspiration/src/routes.ts b/plugins/inspiration/src/routes.ts new file mode 100644 index 0000000..1b97d45 --- /dev/null +++ b/plugins/inspiration/src/routes.ts @@ -0,0 +1,474 @@ +import type { + PluginHttpRequest, + PluginHttpResponse, + PluginRoute, +} from "@echolog/plugin-sdk"; +import { + decodeInspirationCursor, + InspirationStoreError, + type InspirationPage, + type InspirationStoreListFilter, +} from "./store.js"; +import type { + CreateInspirationInput, + Inspiration, + InspirationStatus, + UpdateInspirationInput, +} from "./types.js"; + +const ID_RE = /^[A-Za-z0-9_-]{8,32}$/; +const MAX_CONTENT_LENGTH = 10_000; +const MAX_TAGS = 20; +const MAX_TAG_LENGTH = 50; +const MAX_PROJECT_LENGTH = 100; + +export interface InspirationCaptureStore { + create(input: CreateInspirationInput): Promise; + get(id: string): Promise; + list(filter: InspirationStoreListFilter): Promise; + update(id: string, input: UpdateInspirationInput): Promise; + archive(id: string, expectedVersion: number): Promise; + restore( + id: string, + expectedVersion: number, + status: Exclude + ): Promise; +} + +type ValidationResult = + | { ok: true; value: T } + | { ok: false; error: string }; + +function response(statusCode: number, body: unknown): PluginHttpResponse { + return { statusCode, body }; +} + +function validationError(error: string): PluginHttpResponse { + return response(400, { + error, + code: "INSPIRATION_VALIDATION_ERROR", + }); +} + +function storeError(error: unknown): PluginHttpResponse { + if (!(error instanceof InspirationStoreError)) throw error; + return response(error.statusCode, { + error: error.message, + code: error.code, + ...(error.currentVersion === undefined + ? {} + : { currentVersion: error.currentVersion }), + }); +} + +function objectBody(body: unknown): ValidationResult> { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, error: "body must be an object" }; + } + return { ok: true, value: body as Record }; +} + +function rejectUnknownKeys( + body: Record, + allowed: readonly string[] +): string | null { + const unknown = Object.keys(body).find((key) => !allowed.includes(key)); + return unknown ? `unknown field: ${unknown}` : null; +} + +function normalizeContent(value: unknown): ValidationResult { + if (typeof value !== "string") { + return { ok: false, error: "content must be a string" }; + } + const content = value.trim(); + if (content.length < 1 || content.length > MAX_CONTENT_LENGTH) { + return { + ok: false, + error: `content must contain 1 to ${MAX_CONTENT_LENGTH} characters`, + }; + } + return { ok: true, value: content }; +} + +function normalizeTags(value: unknown): ValidationResult { + if (!Array.isArray(value) || value.length > MAX_TAGS) { + return { + ok: false, + error: `tags must be an array with at most ${MAX_TAGS} entries`, + }; + } + const normalized: string[] = []; + for (const item of value) { + if (typeof item !== "string") { + return { ok: false, error: "each tag must be a string" }; + } + const tag = item.trim().toLowerCase(); + if (tag.length < 1 || tag.length > MAX_TAG_LENGTH) { + return { + ok: false, + error: `each tag must contain 1 to ${MAX_TAG_LENGTH} characters`, + }; + } + if (!normalized.includes(tag)) normalized.push(tag); + } + return { ok: true, value: normalized.sort() }; +} + +function normalizeProject(value: unknown): ValidationResult { + if (value === null) return { ok: true, value: null }; + if (typeof value !== "string") { + return { ok: false, error: "project must be a string or null" }; + } + const project = value.trim(); + if (project.length > MAX_PROJECT_LENGTH) { + return { + ok: false, + error: `project must contain at most ${MAX_PROJECT_LENGTH} characters`, + }; + } + return { ok: true, value: project || null }; +} + +function activeStatus( + value: unknown, + field = "status" +): ValidationResult<"inbox" | "kept"> { + return value === "inbox" || value === "kept" + ? { ok: true, value } + : { ok: false, error: `${field} must be inbox or kept` }; +} + +function expectedVersion(value: unknown): ValidationResult { + return Number.isInteger(value) && Number(value) >= 1 + ? { ok: true, value: Number(value) } + : { ok: false, error: "expectedVersion must be a positive integer" }; +} + +function validateCreate(body: unknown): ValidationResult { + const object = objectBody(body); + if (!object.ok) return object; + const unknown = rejectUnknownKeys(object.value, [ + "content", + "tags", + "project", + "status", + ]); + if (unknown) return { ok: false, error: unknown }; + const content = normalizeContent(object.value.content); + if (!content.ok) return content; + const tags = normalizeTags(object.value.tags ?? []); + if (!tags.ok) return tags; + const project = normalizeProject(object.value.project ?? null); + if (!project.ok) return project; + const status = activeStatus(object.value.status ?? "inbox"); + if (!status.ok) return status; + return { + ok: true, + value: { + content: content.value, + tags: tags.value, + project: project.value, + status: status.value, + }, + }; +} + +function validateUpdate(body: unknown): ValidationResult { + const object = objectBody(body); + if (!object.ok) return object; + const unknown = rejectUnknownKeys(object.value, [ + "expectedVersion", + "content", + "tags", + "project", + "status", + ]); + if (unknown) return { ok: false, error: unknown }; + const version = expectedVersion(object.value.expectedVersion); + if (!version.ok) return version; + const mutable = ["content", "tags", "project", "status"] + .filter((key) => Object.hasOwn(object.value, key)); + if (!mutable.length) { + return { ok: false, error: "at least one mutable field is required" }; + } + const result: UpdateInspirationInput = { expectedVersion: version.value }; + if (Object.hasOwn(object.value, "content")) { + const content = normalizeContent(object.value.content); + if (!content.ok) return content; + result.content = content.value; + } + if (Object.hasOwn(object.value, "tags")) { + const tags = normalizeTags(object.value.tags); + if (!tags.ok) return tags; + result.tags = tags.value; + } + if (Object.hasOwn(object.value, "project")) { + const project = normalizeProject(object.value.project); + if (!project.ok) return project; + result.project = project.value; + } + if (Object.hasOwn(object.value, "status")) { + const status = activeStatus(object.value.status); + if (!status.ok) return status; + result.status = status.value; + } + return { ok: true, value: result }; +} + +function validateVersionBody(body: unknown): ValidationResult { + const object = objectBody(body); + if (!object.ok) return object; + const unknown = rejectUnknownKeys(object.value, ["expectedVersion"]); + if (unknown) return { ok: false, error: unknown }; + return expectedVersion(object.value.expectedVersion); +} + +function validateRestore(body: unknown): ValidationResult<{ + expectedVersion: number; + status: "inbox" | "kept"; +}> { + const object = objectBody(body); + if (!object.ok) return object; + const unknown = rejectUnknownKeys(object.value, ["expectedVersion", "status"]); + if (unknown) return { ok: false, error: unknown }; + const version = expectedVersion(object.value.expectedVersion); + if (!version.ok) return version; + const status = activeStatus(object.value.status ?? "inbox"); + if (!status.ok) return status; + return { + ok: true, + value: { expectedVersion: version.value, status: status.value }, + }; +} + +function queryObject(query: unknown): ValidationResult> { + if (query == null) return { ok: true, value: {} }; + if (typeof query !== "object" || Array.isArray(query)) { + return { ok: false, error: "query must be an object" }; + } + const value = query as Record; + const unknown = rejectUnknownKeys(value, [ + "text", + "tag", + "project", + "status", + "includeArchived", + "createdBefore", + "createdAfter", + "cursor", + "limit", + ]); + return unknown ? { ok: false, error: unknown } : { ok: true, value }; +} + +function singleQueryString( + value: unknown, + name: string +): ValidationResult { + if (value === undefined) return { ok: true, value: undefined }; + return typeof value === "string" + ? { ok: true, value } + : { ok: false, error: `${name} must be specified once` }; +} + +function repeatedQueryStrings( + value: unknown, + name: string +): ValidationResult { + if (value === undefined) return { ok: true, value: undefined }; + const values = Array.isArray(value) ? value : [value]; + if (!values.length || values.some((item) => typeof item !== "string")) { + return { ok: false, error: `${name} must contain strings` }; + } + return { ok: true, value: values as string[] }; +} + +function isoDate(value: string, name: string): ValidationResult { + if (!value.includes("T")) { + return { ok: false, error: `${name} must be an ISO 8601 timestamp` }; + } + const date = new Date(value); + return Number.isFinite(date.getTime()) + ? { ok: true, value: date } + : { ok: false, error: `${name} must be an ISO 8601 timestamp` }; +} + +function validateList(query: unknown): ValidationResult { + const object = queryObject(query); + if (!object.ok) return object; + const text = singleQueryString(object.value.text, "text"); + if (!text.ok) return text; + if (text.value !== undefined && (text.value.trim().length < 1 || text.value.length > 500)) { + return { ok: false, error: "text must contain 1 to 500 characters" }; + } + const project = singleQueryString(object.value.project, "project"); + if (!project.ok) return project; + if (project.value !== undefined && (project.value.trim().length < 1 || project.value.length > MAX_PROJECT_LENGTH)) { + return { ok: false, error: `project must contain 1 to ${MAX_PROJECT_LENGTH} characters` }; + } + const rawTags = repeatedQueryStrings(object.value.tag, "tag"); + if (!rawTags.ok) return rawTags; + const tags = normalizeTags(rawTags.value ?? []); + if (!tags.ok) return tags; + const rawStatuses = repeatedQueryStrings(object.value.status, "status"); + if (!rawStatuses.ok) return rawStatuses; + const statuses: InspirationStatus[] = []; + for (const status of rawStatuses.value ?? []) { + if (status !== "inbox" && status !== "kept" && status !== "archived") { + return { ok: false, error: "status must be inbox, kept, or archived" }; + } + if (!statuses.includes(status)) statuses.push(status); + } + const rawIncludeArchived = singleQueryString( + object.value.includeArchived, + "includeArchived" + ); + if (!rawIncludeArchived.ok) return rawIncludeArchived; + if ( + rawIncludeArchived.value !== undefined && + rawIncludeArchived.value !== "true" && + rawIncludeArchived.value !== "false" + ) { + return { ok: false, error: "includeArchived must be true or false" }; + } + const rawLimit = singleQueryString(object.value.limit, "limit"); + if (!rawLimit.ok) return rawLimit; + const limit = rawLimit.value === undefined ? 50 : Number(rawLimit.value); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + return { ok: false, error: "limit must be an integer from 1 to 100" }; + } + const rawBefore = singleQueryString(object.value.createdBefore, "createdBefore"); + if (!rawBefore.ok) return rawBefore; + const rawAfter = singleQueryString(object.value.createdAfter, "createdAfter"); + if (!rawAfter.ok) return rawAfter; + const rawCursor = singleQueryString(object.value.cursor, "cursor"); + if (!rawCursor.ok) return rawCursor; + let before: Date | undefined; + let beforeId: string | undefined; + if (rawCursor.value !== undefined) { + const cursor = decodeInspirationCursor(rawCursor.value); + if (!cursor) return { ok: false, error: "cursor is invalid" }; + before = cursor.before; + beforeId = cursor.beforeId; + } else if (rawBefore.value !== undefined) { + const parsed = isoDate(rawBefore.value, "createdBefore"); + if (!parsed.ok) return parsed; + before = parsed.value; + } + let after: Date | undefined; + if (rawAfter.value !== undefined) { + const parsed = isoDate(rawAfter.value, "createdAfter"); + if (!parsed.ok) return parsed; + after = parsed.value; + } + if (before && after && before <= after) { + return { ok: false, error: "createdBefore must be later than createdAfter" }; + } + return { + ok: true, + value: { + ...(text.value === undefined ? {} : { text: text.value.trim() }), + ...(tags.value.length ? { tags: tags.value } : {}), + ...(project.value === undefined ? {} : { project: project.value.trim() }), + ...(statuses.length ? { statuses } : {}), + includeArchived: rawIncludeArchived.value === "true", + limit, + ...(before ? { before } : {}), + ...(beforeId ? { beforeId } : {}), + ...(after ? { after } : {}), + }, + }; +} + +function validId(id: string): PluginHttpResponse | null { + return ID_RE.test(id) + ? null + : validationError("inspiration id is invalid"); +} + +export function createInspirationRoutes( + store: () => InspirationCaptureStore +): PluginRoute[] { + return [ + { + method: "POST", + path: "/api/plugins/inspiration/inspirations", + async handler(request: PluginHttpRequest) { + const validated = validateCreate(request.body); + if (!validated.ok) return validationError(validated.error); + return response(201, await store().create(validated.value)); + }, + }, + { + method: "GET", + path: "/api/plugins/inspiration/inspirations", + async handler(request: PluginHttpRequest) { + const validated = validateList(request.query); + if (!validated.ok) return validationError(validated.error); + return store().list(validated.value); + }, + }, + { + method: "GET", + path: "/api/plugins/inspiration/inspirations/:id", + async handler(request: PluginHttpRequest) { + const invalid = validId(request.params.id); + if (invalid) return invalid; + const row = await store().get(request.params.id); + return row ?? response(404, { + error: `Inspiration ${request.params.id} not found`, + code: "INSPIRATION_NOT_FOUND", + }); + }, + }, + { + method: "PATCH", + path: "/api/plugins/inspiration/inspirations/:id", + async handler(request: PluginHttpRequest) { + const invalid = validId(request.params.id); + if (invalid) return invalid; + const validated = validateUpdate(request.body); + if (!validated.ok) return validationError(validated.error); + try { + return await store().update(request.params.id, validated.value); + } catch (error) { + return storeError(error); + } + }, + }, + { + method: "POST", + path: "/api/plugins/inspiration/inspirations/:id/archive", + async handler(request: PluginHttpRequest) { + const invalid = validId(request.params.id); + if (invalid) return invalid; + const validated = validateVersionBody(request.body); + if (!validated.ok) return validationError(validated.error); + try { + return await store().archive(request.params.id, validated.value); + } catch (error) { + return storeError(error); + } + }, + }, + { + method: "POST", + path: "/api/plugins/inspiration/inspirations/:id/restore", + async handler(request: PluginHttpRequest) { + const invalid = validId(request.params.id); + if (invalid) return invalid; + const validated = validateRestore(request.body); + if (!validated.ok) return validationError(validated.error); + try { + return await store().restore( + request.params.id, + validated.value.expectedVersion, + validated.value.status + ); + } catch (error) { + return storeError(error); + } + }, + }, + ]; +} diff --git a/plugins/inspiration/src/schema.ts b/plugins/inspiration/src/schema.ts new file mode 100644 index 0000000..dac1d4a --- /dev/null +++ b/plugins/inspiration/src/schema.ts @@ -0,0 +1,183 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + check, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { + FlowDeliveryStatus, + FlowOutcome, + FlowSource, + InspirationStatus, +} from "./types.js"; + +export const inspirations = pgTable( + "inspirations", + { + id: text("id").primaryKey(), + version: integer("version").notNull().default(1), + content: text("content").notNull(), + tags: text("tags").array().notNull().default(sql`'{}'::text[]`), + project: text("project"), + status: text("status").$type().notNull().default("inbox"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + archivedAt: timestamp("archived_at", { withTimezone: true }), + lastSurfacedAt: timestamp("last_surfaced_at", { withTimezone: true }), + }, + (table) => [ + check("inspirations_version_check", sql`${table.version} >= 1`), + check( + "inspirations_content_check", + sql`char_length(trim(${table.content})) BETWEEN 1 AND 10000` + ), + check( + "inspirations_status_check", + sql`${table.status} IN ('inbox', 'kept', 'archived')` + ), + check( + "inspirations_archive_check", + sql`(${table.status} = 'archived') = (${table.archivedAt} IS NOT NULL)` + ), + index("idx_inspirations_flow_selection").on( + table.status, + table.lastSurfacedAt.asc().nullsFirst(), + table.createdAt.asc(), + table.id.asc() + ), + index("idx_inspirations_history").on( + table.createdAt.desc(), + table.id.desc() + ), + index("idx_inspirations_project_status").on(table.project, table.status), + index("idx_inspirations_tags").using("gin", table.tags), + ] +); + +export const inspirationFlowSettings = pgTable( + "inspiration_flow_settings", + { + id: text("id").primaryKey().default("default"), + version: integer("version").notNull().default(1), + enabled: boolean("enabled").notNull().default(false), + intervalMinutes: integer("interval_minutes").notNull().default(240), + quietStartMinute: integer("quiet_start_minute").notNull().default(1_320), + quietEndMinute: integer("quiet_end_minute").notNull().default(480), + cooldownMinutes: integer("cooldown_minutes").notNull().default(1_440), + dailyLimit: integer("daily_limit").notNull().default(3), + defaultSnoozeMinutes: integer("default_snooze_minutes").notNull().default(1_440), + statuses: text("statuses") + .array() + .notNull() + .default(sql`ARRAY['inbox', 'kept']::text[]`), + tags: text("tags").array().notNull().default(sql`'{}'::text[]`), + projects: text("projects").array().notNull().default(sql`'{}'::text[]`), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + check("inspiration_flow_settings_singleton", sql`${table.id} = 'default'`), + check("inspiration_flow_settings_version_check", sql`${table.version} >= 1`), + check( + "inspiration_flow_settings_interval_check", + sql`${table.intervalMinutes} BETWEEN 1 AND 10080` + ), + check( + "inspiration_flow_settings_quiet_start_check", + sql`${table.quietStartMinute} BETWEEN 0 AND 1439` + ), + check( + "inspiration_flow_settings_quiet_end_check", + sql`${table.quietEndMinute} BETWEEN 0 AND 1439` + ), + check( + "inspiration_flow_settings_cooldown_check", + sql`${table.cooldownMinutes} BETWEEN 0 AND 525600` + ), + check( + "inspiration_flow_settings_daily_limit_check", + sql`${table.dailyLimit} BETWEEN 1 AND 1000` + ), + check( + "inspiration_flow_settings_snooze_check", + sql`${table.defaultSnoozeMinutes} BETWEEN 1 AND 525600` + ), + check( + "inspiration_flow_settings_statuses_check", + sql`${table.statuses} <@ ARRAY['inbox', 'kept']::text[]` + ), + ] +); + +export const inspirationFlowDeliveries = pgTable( + "inspiration_flow_deliveries", + { + id: text("id").primaryKey(), + version: integer("version").notNull().default(1), + attempts: integer("attempts").notNull().default(1), + inspirationId: text("inspiration_id") + .notNull() + .references(() => inspirations.id, { onDelete: "restrict" }), + source: text("source").$type().notNull(), + dedupeKey: text("dedupe_key").notNull(), + status: text("status").$type().notNull(), + outcome: text("outcome").$type(), + surfacedAt: timestamp("surfaced_at", { withTimezone: true }).notNull(), + notifiedAt: timestamp("notified_at", { withTimezone: true }), + snoozedUntil: timestamp("snoozed_until", { withTimezone: true }), + outcomeAt: timestamp("outcome_at", { withTimezone: true }), + notificationChannel: text("notification_channel"), + error: text("error"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + check("inspiration_flow_deliveries_version_check", sql`${table.version} >= 1`), + check("inspiration_flow_deliveries_attempts_check", sql`${table.attempts} >= 1`), + check( + "inspiration_flow_deliveries_source_check", + sql`${table.source} IN ('manual', 'scheduled')` + ), + check( + "inspiration_flow_deliveries_status_check", + sql`${table.status} IN ('reserved', 'sent', 'failed', 'acted')` + ), + check( + "inspiration_flow_deliveries_outcome_check", + sql`${table.outcome} IS NULL OR ${table.outcome} IN ('viewed', 'continued', 'kept', 'later', 'archived')` + ), + check( + "inspiration_flow_deliveries_acted_check", + sql`(${table.status} = 'acted') = (${table.outcome} IS NOT NULL AND ${table.outcomeAt} IS NOT NULL)` + ), + uniqueIndex("idx_inspiration_flow_deliveries_dedupe_key").on(table.dedupeKey), + index("idx_inspiration_flow_deliveries_inspiration_surfaced").on( + table.inspirationId, + table.surfacedAt.desc() + ), + index("idx_inspiration_flow_deliveries_status_created").on( + table.status, + table.createdAt + ), + index("idx_inspiration_flow_deliveries_surfaced_at").on(table.surfacedAt), + index("idx_inspiration_flow_deliveries_snoozed_until").on(table.snoozedUntil), + ] +); + +export type InspirationRow = typeof inspirations.$inferSelect; +export type InspirationFlowSettingsRow = typeof inspirationFlowSettings.$inferSelect; +export type InspirationFlowDeliveryRow = typeof inspirationFlowDeliveries.$inferSelect; diff --git a/plugins/inspiration/src/selector.ts b/plugins/inspiration/src/selector.ts new file mode 100644 index 0000000..62edfb1 --- /dev/null +++ b/plugins/inspiration/src/selector.ts @@ -0,0 +1,175 @@ +import type { + FlowSettings, + FlowSource, + Inspiration, +} from "./types.js"; + +export interface SelectableInspiration { + inspiration: Inspiration; + snoozedUntil: Date | null; +} + +export interface FlowSelectionInput { + candidates: SelectableInspiration[]; + settings: FlowSettings; + source: FlowSource; + now: Date; + surfacedToday: number; +} + +export interface FlowSelectionResult { + selected: SelectableInspiration | null; + explanation: string[]; + excluded: Record; +} + +export function minuteOfLocalDay(date: Date): number { + return date.getHours() * 60 + date.getMinutes(); +} + +export function isQuietMinute( + minute: number, + startMinute: number, + endMinute: number +): boolean { + // Equal endpoints intentionally disable quiet hours. This makes the + // singleton setting able to represent "no quiet period" without another + // nullable/configuration flag. + if (startMinute === endMinute) return false; + if (startMinute < endMinute) { + return minute >= startMinute && minute < endMinute; + } + return minute >= startMinute || minute < endMinute; +} + +export function candidateExclusionReasons( + candidate: SelectableInspiration, + settings: FlowSettings, + now: Date +): string[] { + const { inspiration, snoozedUntil } = candidate; + const reasons: string[] = []; + + if (inspiration.status === "archived") { + reasons.push("lifecycle:archived"); + } else if (!settings.statuses.includes(inspiration.status)) { + reasons.push(`filter:status:${inspiration.status}`); + } + + if ( + settings.tags.length > 0 && + !settings.tags.every((tag) => inspiration.tags.includes(tag)) + ) { + reasons.push("filter:tags"); + } + + if ( + settings.projects.length > 0 && + (inspiration.project === null || + !settings.projects.includes(inspiration.project)) + ) { + reasons.push("filter:project"); + } + + if (snoozedUntil && snoozedUntil.getTime() > now.getTime()) { + reasons.push(`delivery:snoozed-until:${snoozedUntil.toISOString()}`); + } + + if (inspiration.lastSurfacedAt && settings.cooldownMinutes > 0) { + const eligibleAt = new Date( + inspiration.lastSurfacedAt.getTime() + settings.cooldownMinutes * 60_000 + ); + if (eligibleAt.getTime() > now.getTime()) { + reasons.push(`policy:cooldown-until:${eligibleAt.toISOString()}`); + } + } + + return reasons; +} + +export function compareSelectableInspirations( + left: SelectableInspiration, + right: SelectableInspiration +): number { + const leftSurfaced = left.inspiration.lastSurfacedAt?.getTime(); + const rightSurfaced = right.inspiration.lastSurfacedAt?.getTime(); + if (leftSurfaced === undefined && rightSurfaced !== undefined) return -1; + if (leftSurfaced !== undefined && rightSurfaced === undefined) return 1; + if (leftSurfaced !== rightSurfaced) { + return (leftSurfaced ?? 0) - (rightSurfaced ?? 0); + } + + const createdDifference = + left.inspiration.createdAt.getTime() - right.inspiration.createdAt.getTime(); + if (createdDifference !== 0) return createdDifference; + if (left.inspiration.id === right.inspiration.id) return 0; + return left.inspiration.id < right.inspiration.id ? -1 : 1; +} + +export function selectFlowCandidate( + input: FlowSelectionInput +): FlowSelectionResult { + const globalReasons: string[] = []; + if (input.source === "scheduled") { + if (!input.settings.enabled) globalReasons.push("policy:disabled"); + if ( + isQuietMinute( + minuteOfLocalDay(input.now), + input.settings.quietStartMinute, + input.settings.quietEndMinute + ) + ) { + globalReasons.push("policy:quiet-hours"); + } + } + if (input.surfacedToday >= input.settings.dailyLimit) { + globalReasons.push("policy:daily-limit"); + } + + const excluded: Record = {}; + const eligible: SelectableInspiration[] = []; + for (const candidate of input.candidates) { + const reasons = candidateExclusionReasons( + candidate, + input.settings, + input.now + ); + if (reasons.length === 0) eligible.push(candidate); + else excluded[candidate.inspiration.id] = reasons; + } + const excludedExplanations = Object.entries(excluded).flatMap( + ([id, reasons]) => reasons.map((reason) => `excluded:${id}:${reason}`) + ); + + if (globalReasons.length > 0) { + return { + selected: null, + explanation: [...globalReasons, ...excludedExplanations], + excluded, + }; + } + if (eligible.length === 0) { + return { + selected: null, + explanation: input.candidates.length === 0 + ? ["selection:no-inspirations"] + : ["selection:no-eligible-inspirations", ...excludedExplanations], + excluded, + }; + } + + eligible.sort(compareSelectableInspirations); + const selected = eligible[0]!; + const rankReason = selected.inspiration.lastSurfacedAt === null + ? "selection:never-surfaced-first" + : "selection:oldest-last-surfaced-first"; + return { + selected, + explanation: [ + rankReason, + "selection:tiebreak-created-at-then-id", + ...excludedExplanations, + ], + excluded, + }; +} diff --git a/plugins/inspiration/src/store.ts b/plugins/inspiration/src/store.ts new file mode 100644 index 0000000..de4a49e --- /dev/null +++ b/plugins/inspiration/src/store.ts @@ -0,0 +1,301 @@ +import { + and, + arrayContains, + desc, + eq, + gt, + gte, + inArray, + lt, + ne, + or, + sql, + type SQL, +} from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { nanoid } from "nanoid"; +import postgres from "postgres"; +import { inspirations } from "./schema.js"; +import type { + CreateInspirationInput, + Inspiration, + InspirationListFilter, + InspirationStatus, + UpdateInspirationInput, +} from "./types.js"; + +export type InspirationStoreErrorCode = + | "INSPIRATION_NOT_FOUND" + | "INSPIRATION_VERSION_CONFLICT" + | "INSPIRATION_INVALID_STATE"; + +export class InspirationStoreError extends Error { + constructor( + public readonly code: InspirationStoreErrorCode, + message: string, + public readonly statusCode: 404 | 409, + public readonly currentVersion?: number + ) { + super(message); + this.name = "InspirationStoreError"; + } +} + +export interface InspirationStoreListFilter extends InspirationListFilter { + beforeId?: string; + after?: Date; +} + +export interface InspirationPage { + items: Inspiration[]; + nextCursor: string | null; +} + +interface CursorPayload { + createdAt: string; + id: string; +} + +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, "\\$&"); +} + +function encodeCursor(row: Inspiration): string { + return Buffer.from(JSON.stringify({ + createdAt: row.createdAt.toISOString(), + id: row.id, + } satisfies CursorPayload)).toString("base64url"); +} + +export function decodeInspirationCursor(value: string): { + before: Date; + beforeId: string; +} | null { + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8") + ) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const cursor = parsed as Record; + if ( + typeof cursor.createdAt !== "string" || + typeof cursor.id !== "string" || + cursor.id.length < 1 || + cursor.id.length > 64 + ) { + return null; + } + const before = new Date(cursor.createdAt); + if (!Number.isFinite(before.getTime())) return null; + return { before, beforeId: cursor.id }; + } catch { + return null; + } +} + +export class InspirationStore { + private readonly sql; + private readonly db; + + constructor(databaseUrl: string) { + this.sql = postgres(databaseUrl); + this.db = drizzle(this.sql); + } + + async close(): Promise { + await this.sql.end(); + } + + async create(input: CreateInspirationInput): Promise { + const now = new Date(); + const [created] = await this.db + .insert(inspirations) + .values({ + id: nanoid(12), + version: 1, + content: input.content, + tags: input.tags, + project: input.project, + status: input.status, + createdAt: now, + updatedAt: now, + archivedAt: null, + lastSurfacedAt: null, + }) + .returning(); + if (!created) throw new Error("inspiration was not written"); + return created; + } + + async get(id: string): Promise { + const [row] = await this.db + .select() + .from(inspirations) + .where(eq(inspirations.id, id)); + return row ?? null; + } + + async list(filter: InspirationStoreListFilter): Promise { + const conditions: SQL[] = []; + if (!filter.includeArchived) { + conditions.push(ne(inspirations.status, "archived")); + } + if (filter.statuses?.length) { + conditions.push(inArray(inspirations.status, filter.statuses)); + } + if (filter.project !== undefined) { + conditions.push(eq(inspirations.project, filter.project)); + } + if (filter.tags?.length) { + conditions.push(arrayContains(inspirations.tags, filter.tags)); + } + if (filter.text) { + conditions.push( + sql`${inspirations.content} ILIKE ${`%${escapeLike(filter.text)}%`} ESCAPE '\\'` + ); + } + if (filter.after) { + conditions.push(gt(inspirations.createdAt, filter.after)); + } + if (filter.before) { + conditions.push(filter.beforeId + ? or( + lt(inspirations.createdAt, filter.before), + and( + eq(inspirations.createdAt, filter.before), + lt(inspirations.id, filter.beforeId) + ) + )! + : lt(inspirations.createdAt, filter.before)); + } + + const rows = await this.db + .select() + .from(inspirations) + .where(and(...conditions)) + .orderBy(desc(inspirations.createdAt), desc(inspirations.id)) + .limit(filter.limit + 1); + const hasMore = rows.length > filter.limit; + const items = hasMore ? rows.slice(0, filter.limit) : rows; + return { + items, + nextCursor: hasMore && items.length + ? encodeCursor(items[items.length - 1]!) + : null, + }; + } + + async update(id: string, input: UpdateInspirationInput): Promise { + const changes: { + content?: string; + tags?: string[]; + project?: string | null; + status?: Exclude; + } = {}; + if (input.content !== undefined) changes.content = input.content; + if (input.tags !== undefined) changes.tags = input.tags; + if (input.project !== undefined) changes.project = input.project; + if (input.status !== undefined) changes.status = input.status; + + const [updated] = await this.db + .update(inspirations) + .set({ + ...changes, + version: sql`${inspirations.version} + 1`, + updatedAt: new Date(), + }) + .where(and( + eq(inspirations.id, id), + eq(inspirations.version, input.expectedVersion), + ne(inspirations.status, "archived") + )) + .returning(); + if (updated) return updated; + return this.failMutation(id, input.expectedVersion, "update"); + } + + async archive(id: string, expectedVersion: number): Promise { + const now = new Date(); + const [updated] = await this.db + .update(inspirations) + .set({ + status: "archived", + archivedAt: now, + version: sql`${inspirations.version} + 1`, + updatedAt: now, + }) + .where(and( + eq(inspirations.id, id), + eq(inspirations.version, expectedVersion), + ne(inspirations.status, "archived") + )) + .returning(); + if (updated) return updated; + return this.failMutation(id, expectedVersion, "archive"); + } + + async restore( + id: string, + expectedVersion: number, + status: Exclude + ): Promise { + const [updated] = await this.db + .update(inspirations) + .set({ + status, + archivedAt: null, + version: sql`${inspirations.version} + 1`, + updatedAt: new Date(), + }) + .where(and( + eq(inspirations.id, id), + eq(inspirations.version, expectedVersion), + eq(inspirations.status, "archived") + )) + .returning(); + if (updated) return updated; + return this.failMutation(id, expectedVersion, "restore"); + } + + async countCapturedBetween(start: Date, end: Date): Promise { + const [result] = await this.db + .select({ count: sql`count(*)::int` }) + .from(inspirations) + .where(and( + gte(inspirations.createdAt, start), + lt(inspirations.createdAt, end) + )); + return result?.count ?? 0; + } + + private async failMutation( + id: string, + expectedVersion: number, + operation: "update" | "archive" | "restore" + ): Promise { + const current = await this.get(id); + if (!current) { + throw new InspirationStoreError( + "INSPIRATION_NOT_FOUND", + `Inspiration ${id} not found`, + 404 + ); + } + if (current.version !== expectedVersion) { + throw new InspirationStoreError( + "INSPIRATION_VERSION_CONFLICT", + `Inspiration ${id} has changed`, + 409, + current.version + ); + } + throw new InspirationStoreError( + "INSPIRATION_INVALID_STATE", + `Inspiration ${id} cannot be ${operation}d from status ${current.status}`, + 409, + current.version + ); + } +} diff --git a/plugins/inspiration/src/types.ts b/plugins/inspiration/src/types.ts new file mode 100644 index 0000000..54afb38 --- /dev/null +++ b/plugins/inspiration/src/types.ts @@ -0,0 +1,117 @@ +export type InspirationStatus = "inbox" | "kept" | "archived"; + +export interface Inspiration { + id: string; + version: number; + content: string; + tags: string[]; + project: string | null; + status: InspirationStatus; + createdAt: Date; + updatedAt: Date; + archivedAt: Date | null; + lastSurfacedAt: Date | null; +} + +export interface CreateInspirationInput { + content: string; + tags: string[]; + project: string | null; + status: Exclude; +} + +export interface UpdateInspirationInput { + expectedVersion: number; + content?: string; + tags?: string[]; + project?: string | null; + status?: Exclude; +} + +export interface InspirationListFilter { + text?: string; + tags?: string[]; + project?: string; + statuses?: InspirationStatus[]; + includeArchived?: boolean; + limit: number; + before?: Date; +} + +export type FlowSource = "manual" | "scheduled"; +export type FlowDeliveryStatus = "reserved" | "sent" | "failed" | "acted"; +export type FlowOutcome = + | "viewed" + | "continued" + | "kept" + | "later" + | "archived"; + +export interface FlowSettings { + id: "default"; + version: number; + enabled: boolean; + intervalMinutes: number; + quietStartMinute: number; + quietEndMinute: number; + cooldownMinutes: number; + dailyLimit: number; + defaultSnoozeMinutes: number; + statuses: Array>; + tags: string[]; + projects: string[]; + updatedAt: Date; +} + +export interface FlowSettingsUpdate { + expectedVersion: number; + enabled: boolean; + intervalMinutes: number; + quietStartMinute: number; + quietEndMinute: number; + cooldownMinutes: number; + dailyLimit: number; + defaultSnoozeMinutes: number; + statuses: Array>; + tags: string[]; + projects: string[]; +} + +export interface FlowDelivery { + id: string; + version: number; + attempts: number; + inspirationId: string; + source: FlowSource; + dedupeKey: string; + status: FlowDeliveryStatus; + outcome: FlowOutcome | null; + surfacedAt: Date; + notifiedAt: Date | null; + snoozedUntil: Date | null; + outcomeAt: Date | null; + notificationChannel: string | null; + error: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface FlowCandidate { + inspiration: Inspiration; + delivery: FlowDelivery; + explanation: string[]; + duplicate: boolean; +} + +export interface FlowOutcomeInput { + expectedDeliveryVersion: number; + expectedInspirationVersion: number; + outcome: FlowOutcome; + snoozeMinutes?: number; +} + +export interface DailyInspirationSummary { + captured: number; + surfaced: number; + outcomes: Partial>; +} diff --git a/plugins/inspiration/tsconfig.json b/plugins/inspiration/tsconfig.json new file mode 100644 index 0000000..246146a --- /dev/null +++ b/plugins/inspiration/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/plugins/inspiration/tsup.config.ts b/plugins/inspiration/tsup.config.ts new file mode 100644 index 0000000..7c1e5c2 --- /dev/null +++ b/plugins/inspiration/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts", "src/cli.ts"], + outDir: "dist", + format: "esm", + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/plugins/inspiration/web/index.js b/plugins/inspiration/web/index.js new file mode 100644 index 0000000..c9fcc9f --- /dev/null +++ b/plugins/inspiration/web/index.js @@ -0,0 +1,348 @@ +const API_PREFIX = "/plugins/inspiration"; + +function csv(value) { + return String(value ?? "") + .split(/[,,]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function formatMinute(minute) { + const value = Number(minute) || 0; + return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`; +} + +function minuteOfDay(value) { + const [hour, minute] = String(value ?? "").split(":").map(Number); + return hour * 60 + minute; +} + +function listPath(filters) { + const params = new URLSearchParams({ limit: "50" }); + if (filters.text) params.set("text", filters.text); + for (const tag of filters.tags) params.append("tag", tag); + if (filters.project) params.set("project", filters.project); + for (const status of filters.statuses) params.append("status", status); + if (filters.includeArchived) params.set("includeArchived", "true"); + return `${API_PREFIX}/inspirations?${params}`; +} + +export async function activate({ api }) { + let filters = { + text: "", + tags: [], + project: "", + statuses: ["inbox", "kept"], + includeArchived: false, + }; + let latestInspirations = []; + let latestSettings = null; + let latestDeliveries = []; + let currentCandidate = null; + + async function loadSnapshot() { + const [list, settings, ledger] = await Promise.all([ + api(listPath(filters)), + api(`${API_PREFIX}/flow/settings`), + api(`${API_PREFIX}/flow/deliveries?limit=20`), + ]); + latestInspirations = Array.isArray(list?.items) ? list.items : []; + latestSettings = settings; + latestDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : []; + return { + inspirationList: list, + inspirationFlowSettings: settings, + inspirationFlowDeliveries: ledger, + }; + } + + const setError = ($, id, error) => { + const element = $(id); + if (element) element.textContent = error instanceof Error ? error.message : String(error ?? ""); + }; + + return { + id: "inspiration", + faces() { + return [{ type: "inspiration-inbox" }, { type: "inspiration-flow" }]; + }, + load: loadSnapshot, + async loadLive() { + const [list, ledger] = await Promise.all([ + api(listPath(filters)), + api(`${API_PREFIX}/flow/deliveries?limit=20`), + ]); + latestInspirations = Array.isArray(list?.items) ? list.items : []; + latestDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : []; + return { + inspirationList: list, + inspirationFlowDeliveries: ledger, + }; + }, + renderFace(face, { esc, escA }) { + if (face.type === "inspiration-inbox") { + const rows = latestInspirations.map((item) => { + const tags = Array.isArray(item.tags) + ? item.tags.map((tag) => `#${esc(tag)}`).join(" ") + : ""; + const metadata = [item.project, item.status, `v${item.version}`] + .filter(Boolean) + .map((value) => esc(value)) + .join(" · "); + if (item.status === "archived") { + return `
+
${metadata}
+

${esc(item.content)}

+
${tags}
+
+ +
+
`; + } + return `
+
${metadata}
+ +
+ + + +
+
${tags}
+
+
+ + +
+
`; + }).join(""); + return `
+
灵感收件箱
+
+ +
+ + +
+
+
+ +
+
+
+
+ + + + + + + +
+
+ + +
+
+
${rows || '

尚无匹配的灵感。

'}
+
`; + } + + if (face.type === "inspiration-flow") { + const candidate = currentCandidate; + const candidateBody = candidate + ? `
+
本次浮现 · ${esc(candidate.inspiration.project ?? "未分项目")} · v${esc(candidate.inspiration.version)}
+

${esc(candidate.inspiration.content)}

+
${(candidate.inspiration.tags ?? []).map((tag) => `#${esc(tag)}`).join(" ")}
+
${(candidate.explanation ?? []).map((reason) => esc(reason)).join(" · ")}
+
+ +
+
+
+ + + + + +
+
` + : '

点「浮现下一条」使用服务端选择器。

'; + const deliveryRows = latestDeliveries.map((delivery) => + `
+ ${esc(delivery.status)} · ${esc(delivery.outcome ?? "未处理")} + + ${esc(new Date(delivery.surfacedAt).toLocaleString("zh-CN"))} +
` + ).join(""); + const settings = latestSettings; + const settingsBody = settings + ? `
+ + + + + + + + + + + +
` + : '

Flow 设置不可用。

'; + return `
+
灵感 Flow
+
+ +
+ ${candidateBody} +
Flow 设置${settings ? ` · v${esc(settings.version)}` : ""}
+
+ ${settingsBody} +
+ ${settings ? '
' : ""} +
+
投递历史
+
${deliveryRows || '

尚无 Flow 投递。

'}
+
`; + } + return null; + }, + async handleAction(action, { id, $ }) { + if (action === "capture-inspiration") { + try { + await api(`${API_PREFIX}/inspirations`, { + method: "POST", + body: JSON.stringify({ + content: $("inspirationNewContent")?.value ?? "", + tags: csv($("inspirationNewTags")?.value), + project: $("inspirationNewProject")?.value?.trim() || null, + status: "inbox", + }), + }); + return { handled: true, message: "灵感已捕捉" }; + } catch (error) { + setError($, "inspirationNewError", error); + return { handled: true, refresh: false }; + } + } + if (action === "filter-inspirations") { + filters = { + text: $("inspirationFilterText")?.value?.trim() || "", + tags: csv($("inspirationFilterTags")?.value), + project: $("inspirationFilterProject")?.value?.trim() || "", + statuses: [ + ...($("inspirationFilterInbox")?.checked ? ["inbox"] : []), + ...($("inspirationFilterKept")?.checked ? ["kept"] : []), + ...($("inspirationFilterArchived")?.checked ? ["archived"] : []), + ], + includeArchived: Boolean($("inspirationIncludeArchived")?.checked), + }; + return { handled: true, message: "灵感筛选已应用" }; + } + if (action === "clear-inspiration-filters") { + filters = { text: "", tags: [], project: "", statuses: ["inbox", "kept"], includeArchived: false }; + return { handled: true, message: "灵感筛选已清除" }; + } + + const inspiration = latestInspirations.find((item) => item.id === id); + if (action === "edit-inspiration" && inspiration) { + try { + await api(`${API_PREFIX}/inspirations/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ + expectedVersion: inspiration.version, + content: $(`inspirationContent:${id}`)?.value ?? "", + tags: csv($(`inspirationTags:${id}`)?.value), + project: $(`inspirationProject:${id}`)?.value?.trim() || null, + status: $(`inspirationStatus:${id}`)?.value, + }), + }); + return { handled: true, message: "灵感已整理" }; + } catch (error) { + setError($, `inspirationError:${id}`, error); + return { handled: true, refresh: false }; + } + } + if ((action === "archive-inspiration" || action === "restore-inspiration") && inspiration) { + const operation = action === "archive-inspiration" ? "archive" : "restore"; + await api(`${API_PREFIX}/inspirations/${encodeURIComponent(id)}/${operation}`, { + method: "POST", + body: JSON.stringify({ expectedVersion: inspiration.version }), + }); + return { handled: true, message: operation === "archive" ? "灵感已归档" : "灵感已恢复" }; + } + if (action === "next-inspiration") { + const result = await api(`${API_PREFIX}/flow/next`, { + method: "POST", + body: JSON.stringify({}), + }); + currentCandidate = result?.candidate ?? null; + return { + handled: true, + message: currentCandidate ? "浮现了一条灵感" : "暂无符合条件的灵感", + }; + } + if (action.startsWith("inspiration-outcome-") && currentCandidate?.delivery.id === id) { + const outcome = action.slice("inspiration-outcome-".length); + const body = { + expectedDeliveryVersion: currentCandidate.delivery.version, + expectedInspirationVersion: currentCandidate.inspiration.version, + outcome, + }; + if (outcome === "later" && $("inspirationSnooze")?.value !== "") { + body.snoozeMinutes = Number($("inspirationSnooze").value); + } + try { + await api(`${API_PREFIX}/flow/deliveries/${encodeURIComponent(id)}/outcome`, { + method: "POST", + body: JSON.stringify(body), + }); + currentCandidate = null; + return { handled: true, message: "Flow 结果已记录" }; + } catch (error) { + setError($, "inspirationFlowError", error); + return { handled: true, refresh: false }; + } + } + if (action === "save-inspiration-settings" && latestSettings) { + try { + await api(`${API_PREFIX}/flow/settings`, { + method: "PATCH", + body: JSON.stringify({ + expectedVersion: latestSettings.version, + enabled: Boolean($("inspirationFlowEnabled")?.checked), + intervalMinutes: Number($("inspirationFlowInterval")?.value), + quietStartMinute: minuteOfDay($("inspirationFlowQuietStart")?.value), + quietEndMinute: minuteOfDay($("inspirationFlowQuietEnd")?.value), + cooldownMinutes: Number($("inspirationFlowCooldown")?.value), + dailyLimit: Number($("inspirationFlowDailyLimit")?.value), + defaultSnoozeMinutes: Number($("inspirationFlowDefaultSnooze")?.value), + statuses: [ + ...($("inspirationFlowStatusInbox")?.checked ? ["inbox"] : []), + ...($("inspirationFlowStatusKept")?.checked ? ["kept"] : []), + ], + tags: csv($("inspirationFlowTags")?.value), + projects: csv($("inspirationFlowProjects")?.value), + }), + }); + return { handled: true, message: "Flow 设置已保存" }; + } catch (error) { + setError($, "inspirationSettingsError", error); + return { handled: true, refresh: false }; + } + } + return { handled: false }; + }, + async unmount() { + latestInspirations = []; + latestSettings = null; + latestDeliveries = []; + currentCandidate = null; + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f31cd2e..6f497c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@echolog/plugin-inspiration': + specifier: workspace:* + version: link:plugins/inspiration '@echolog/plugin-screen-time': specifier: workspace:* version: link:plugins/screen-time @@ -85,6 +88,28 @@ importers: specifier: ^5.8.3 version: 5.9.3 + plugins/inspiration: + dependencies: + '@echolog/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + drizzle-orm: + specifier: ^0.44.0 + version: 0.44.7(postgres@3.4.9) + nanoid: + specifier: ^5.1.5 + version: 5.1.11 + postgres: + specifier: ^3.4.7 + version: 3.4.9 + devDependencies: + tsup: + specifier: ^8.5.0 + version: 8.5.1(tsx@4.22.2)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + plugins/screen-time: dependencies: '@echolog/plugin-sdk': diff --git a/src/cli/index.ts b/src/cli/index.ts index b9bf254..e0a1ff0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -834,6 +834,485 @@ withJson( }) ); +type InspirationLifecycleStatus = "inbox" | "kept" | "archived"; +type InspirationFlowOutcome = + | "viewed" + | "continued" + | "kept" + | "later" + | "archived"; + +const inspirationApiPrefix = "/api/plugins/inspiration"; + +function inspirationInteger(value: string, option: string, minimum = 0): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < minimum) { + throw new CliUsageError(`${option} 必须是大于或等于 ${minimum} 的整数`); + } + return parsed; +} + +function inspirationLimit(value: string, option: string): number { + const parsed = inspirationInteger(value, option, 1); + if (parsed > 100) throw new CliUsageError(`${option} 必须是 1 到 100 的整数`); + return parsed; +} + +function inspirationBoolean(value: string, option: string): boolean { + if (value === "true") return true; + if (value === "false") return false; + throw new CliUsageError(`${option} 只能是 true 或 false`); +} + +function inspirationStatus(value: string, allowArchived = true): InspirationLifecycleStatus { + if (value === "inbox" || value === "kept" || (allowArchived && value === "archived")) { + return value; + } + throw new CliUsageError( + allowArchived + ? "status 只能是 inbox、kept 或 archived" + : "status 只能是 inbox 或 kept" + ); +} + +function inspirationOutcome(value: string): InspirationFlowOutcome { + if ( + value === "viewed" || + value === "continued" || + value === "kept" || + value === "later" || + value === "archived" + ) { + return value; + } + throw new CliUsageError( + "outcome 只能是 viewed、continued、kept、later 或 archived" + ); +} + +function inspirationMinute(value: string, option: string): number { + const match = /^(\d{2}):(\d{2})$/.exec(value); + if (!match) throw new CliUsageError(`${option} 必须是 HH:mm`); + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) { + throw new CliUsageError(`${option} 必须是有效的 24 小时时间`); + } + return hour * 60 + minute; +} + +function inspirationItems(result: any): any[] { + if (Array.isArray(result)) return result; + if (Array.isArray(result?.items)) return result.items; + return []; +} + +function printInspirations(result: any): void { + const items = inspirationItems(result); + if (items.length === 0) { + console.log("暂无灵感"); + return; + } + for (const item of items) { + const tags = item.tags?.length ? ` #${item.tags.join(" #")}` : ""; + const project = item.project ? ` · ${item.project}` : ""; + console.log(`${item.id}\tv${item.version}\t${item.status}${project}${tags}`); + console.log(` ${item.content}`); + } +} + +const inspiration = program + .command("inspiration") + .description("独立捕捉、整理灵感并使用确定性的 Inspiration Flow;不依赖活跃记录。") + .addHelpText( + "after", + ` +示例: + $ el inspiration capture "为发布页画一张对照图" --tags design,launch + $ el inspiration list --statuses inbox,kept --json + $ el inspiration flow next --json + $ el inspiration flow outcome later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120 +` + ); + +withJson( + inspiration + .command("capture ") + .description("捕捉一条独立灵感;status 只能是 inbox 或 kept,默认 inbox。") + .option("-t, --tags ", "标签,逗号分隔,如 design,launch") + .option("-p, --project ", "可选自由文本项目分组") + .option("--status ", "生命周期状态: inbox | kept", "inbox") + .addHelpText( + "after", + ` +示例: + $ el inspiration capture "试试更短的 onboarding" --tags product,ux + $ el inspiration capture "保留这条原则" --status kept --project EchoLog --json +` + ) +).action( + action(async ( + thisCommand, + content: string, + opts: { tags?: string; project?: string; status: string } + ) => { + const created = await post(`${inspirationApiPrefix}/inspirations`, { + content, + tags: splitCsv(opts.tags), + project: opts.project?.trim() || null, + status: inspirationStatus(opts.status, false), + }); + printSuccess(thisCommand, created, () => { + console.log(`✓ 已捕捉灵感 [${(created as any).id}] v${(created as any).version}`); + console.log(` ${(created as any).content}`); + }); + }) +); + +withJson( + inspiration + .command("list") + .alias("inbox") + .description("列出或筛选灵感;支持文本、标签、项目、生命周期与归档历史。") + .option("--text ", "正文包含的文本") + .option("--tags ", "必须匹配的标签,逗号分隔") + .option("--project ", "精确项目分组") + .option("--statuses ", "状态,逗号分隔: inbox | kept | archived") + .option("--include-archived", "包含 archived 历史") + .option("--limit ", "返回数量,范围 1–100", "50") + .option("--created-before ", "只看此创建时间之前,ISO 8601 且包含时区") + .option("--created-after ", "只看此创建时间之后,ISO 8601 且包含时区") + .option("--cursor ", "上一页响应的 opaque nextCursor") + .addHelpText( + "after", + ` +示例: + $ el inspiration list + $ el inspiration inbox --text onboarding --tags ux,product + $ el inspiration list --statuses kept,archived --include-archived --created-before 2026-08-24T12:00:00+08:00 --json +` + ) +).action( + action(async (thisCommand, opts: { + text?: string; + tags?: string; + project?: string; + statuses?: string; + includeArchived?: boolean; + limit: string; + createdBefore?: string; + createdAfter?: string; + cursor?: string; + }) => { + const params = new URLSearchParams(); + if (opts.text) params.set("text", opts.text); + for (const tag of splitCsv(opts.tags)) params.append("tag", tag); + if (opts.project) params.set("project", opts.project); + if (opts.statuses) { + const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value)); + for (const status of statuses) params.append("status", status); + } + if (opts.includeArchived) params.set("includeArchived", "true"); + params.set("limit", String(inspirationLimit(opts.limit, "--limit"))); + if (opts.createdBefore) params.set("createdBefore", opts.createdBefore); + if (opts.createdAfter) params.set("createdAfter", opts.createdAfter); + if (opts.cursor) params.set("cursor", opts.cursor); + const result = await api(`${inspirationApiPrefix}/inspirations?${params}`); + printSuccess(thisCommand, result, () => printInspirations(result)); + }) +); + +withJson( + inspiration + .command("show ") + .description("查看一条灵感;id 来自 inspiration list。") + .addHelpText("after", `\n示例:\n $ el inspiration show --json\n`) +).action( + action(async (thisCommand, id: string) => { + const item = await api(`${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`); + printSuccess(thisCommand, item, () => printInspirations([item])); + }) +); + +withJson( + inspiration + .command("edit ") + .description("按 expectedVersion 编辑正文、标签、项目或 inbox/kept 状态;冲突返回 409。") + .requiredOption("--version ", "当前 inspiration version,必须与服务端一致") + .option("--content ", "替换正文") + .option("--tags ", "替换标签,逗号分隔;空字符串清空") + .option("--project ", "替换项目分组") + .option("--clear-project", "清除项目分组") + .option("--status ", "生命周期状态: inbox | kept") + .addHelpText( + "after", + ` +示例: + $ el inspiration edit --version 2 --content "更明确的想法" --tags product,copy + $ el inspiration edit --version 3 --clear-project --status kept --json +` + ) +).action( + action(async (thisCommand, id: string, opts: { + version: string; + content?: string; + tags?: string; + project?: string; + clearProject?: boolean; + status?: string; + }) => { + if (opts.project != null && opts.clearProject) { + throw new CliUsageError("--project 和 --clear-project 不能同时使用"); + } + const body: Record = { + expectedVersion: inspirationInteger(opts.version, "--version", 1), + }; + if (opts.content != null) body.content = opts.content; + if (opts.tags != null) body.tags = splitCsv(opts.tags); + if (opts.project != null) body.project = opts.project.trim() || null; + if (opts.clearProject) body.project = null; + if (opts.status != null) body.status = inspirationStatus(opts.status, false); + if (Object.keys(body).length === 1) { + throw new CliUsageError("至少指定 --content、--tags、--project、--clear-project 或 --status 之一"); + } + const updated = await patch( + `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`, + body + ); + printSuccess(thisCommand, updated, () => { + console.log(`✓ 已更新灵感 [${(updated as any).id}] v${(updated as any).version}`); + }); + }) +); + +for (const operation of ["archive", "restore"] as const) { + withJson( + inspiration + .command(`${operation} `) + .description( + operation === "archive" + ? "按 expectedVersion 归档灵感;历史仍可查询。" + : "按 expectedVersion 将已归档灵感恢复到 inbox。" + ) + .requiredOption("--version ", "当前 inspiration version,必须与服务端一致") + .addHelpText( + "after", + `\n示例:\n $ el inspiration ${operation} --version 2 --json\n` + ) + ).action( + action(async (thisCommand, id: string, opts: { version: string }) => { + const result = await post( + `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}/${operation}`, + { expectedVersion: inspirationInteger(opts.version, "--version", 1) } + ); + printSuccess(thisCommand, result, () => { + console.log( + `✓ 灵感已${operation === "archive" ? "归档" : "恢复"} [${(result as any).id}] v${(result as any).version}` + ); + }); + }) + ); +} + +const inspirationFlow = inspiration + .command("flow") + .description("手动浮现灵感、记录用户结果,并查看 Flow 设置与投递历史。") + .addHelpText( + "after", + ` +示例: + $ el inspiration flow next --idempotency-key manual-20260824 --json + $ el inspiration flow deliveries --limit 20 +` + ); + +withJson( + inspirationFlow + .command("next") + .description("使用服务端确定性选择器浮现下一条;不在客户端推断候选。") + .option("--idempotency-key ", "可选手动幂等键,最长 200 字符") + .addHelpText( + "after", + `\n示例:\n $ el inspiration flow next\n $ el inspiration flow next --idempotency-key morning-review --json\n` + ) +).action( + action(async (thisCommand, opts: { idempotencyKey?: string }) => { + const result = await post(`${inspirationApiPrefix}/flow/next`, + opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {} + ); + printSuccess(thisCommand, result, () => { + const candidate = (result as any).candidate; + if (!candidate) { + console.log("暂无可浮现的灵感"); + for (const reason of (result as any).explanation ?? []) console.log(` - ${reason}`); + return; + } + console.log(`${candidate.inspiration.content}`); + console.log(` inspiration ${candidate.inspiration.id} v${candidate.inspiration.version}`); + console.log(` delivery ${candidate.delivery.id} v${candidate.delivery.version}`); + for (const reason of candidate.explanation ?? []) console.log(` - ${reason}`); + }); + }) +); + +withJson( + inspirationFlow + .command("outcome ") + .description("记录 Flow 结果: viewed | continued | kept | later | archived。") + .requiredOption("--delivery-version ", "当前 delivery version") + .requiredOption("--inspiration-version ", "候选 inspiration version") + .option("--snooze-minutes ", "later 的稍后分钟数;省略时使用服务端默认值") + .addHelpText( + "after", + ` +示例: + $ el inspiration flow outcome viewed --delivery-version 1 --inspiration-version 3 + $ el inspiration flow outcome later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120 --json +` + ) +).action( + action(async (thisCommand, deliveryId: string, outcomeValue: string, opts: { + deliveryVersion: string; + inspirationVersion: string; + snoozeMinutes?: string; + }) => { + const outcome = inspirationOutcome(outcomeValue); + const body: Record = { + expectedDeliveryVersion: inspirationInteger(opts.deliveryVersion, "--delivery-version", 1), + expectedInspirationVersion: inspirationInteger(opts.inspirationVersion, "--inspiration-version", 1), + outcome, + }; + if (opts.snoozeMinutes != null) { + if (outcome !== "later") { + throw new CliUsageError("--snooze-minutes 只能与 outcome=later 一起使用"); + } + body.snoozeMinutes = inspirationInteger(opts.snoozeMinutes, "--snooze-minutes", 1); + } + const result = await post( + `${inspirationApiPrefix}/flow/deliveries/${encodeURIComponent(deliveryId)}/outcome`, + body + ); + printSuccess(thisCommand, result, () => { + console.log(`✓ 已记录 Flow 结果: ${outcome}`); + }); + }) +); + +const inspirationFlowSettings = inspirationFlow + .command("settings") + .description("查看 Flow 设置;使用 settings set 提交完整的版本化设置。") + .addHelpText( + "after", + `\n示例:\n $ el inspiration flow settings --json\n $ el inspiration flow settings set --help\n` + ); + +withJson(inspirationFlowSettings).action( + action(async (thisCommand) => { + const settings = await api(`${inspirationApiPrefix}/flow/settings`); + printSuccess(thisCommand, settings, () => { + const value = settings as any; + console.log(`Flow: ${value.enabled ? "已启用" : "未启用"} · v${value.version}`); + console.log(` 周期 ${value.intervalMinutes} 分钟 · 冷却 ${value.cooldownMinutes} 分钟 · 每日上限 ${value.dailyLimit}`); + console.log(` 安静时间 ${formatMinute(value.quietStartMinute)}–${formatMinute(value.quietEndMinute)}`); + }); + }) +); + +withJson( + inspirationFlowSettings + .command("set") + .description("提交完整 FlowSettingsUpdate;所有选项必填,版本冲突返回 409。") + .requiredOption("--version ", "当前 settings version") + .requiredOption("--enabled ", "是否启用定时 Flow: true | false") + .requiredOption("--interval-minutes ", "定时检查间隔分钟数") + .requiredOption("--quiet-start ", "安静时间开始,HH:mm") + .requiredOption("--quiet-end ", "安静时间结束,HH:mm;开始晚于结束表示跨夜") + .requiredOption("--cooldown-minutes ", "同一灵感冷却分钟数") + .requiredOption("--daily-limit ", "每日浮现上限") + .requiredOption("--default-snooze-minutes ", "later 默认稍后分钟数") + .requiredOption("--statuses ", "候选状态,逗号分隔: inbox | kept") + .requiredOption("--tags ", "可选标签筛选,逗号分隔;传空字符串表示不限") + .requiredOption("--projects ", "可选项目筛选,逗号分隔;传空字符串表示不限") + .addHelpText( + "after", + ` +示例: + $ el inspiration flow settings set --version 1 --enabled true --interval-minutes 180 --quiet-start 22:00 --quiet-end 08:00 --cooldown-minutes 1440 --daily-limit 3 --default-snooze-minutes 120 --statuses inbox,kept --tags "" --projects "" --json +` + ) +).action( + action(async (thisCommand, opts: { + version: string; + enabled: string; + intervalMinutes: string; + quietStart: string; + quietEnd: string; + cooldownMinutes: string; + dailyLimit: string; + defaultSnoozeMinutes: string; + statuses: string; + tags: string; + projects: string; + }) => { + const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value, false)); + if (statuses.length === 0) throw new CliUsageError("--statuses 至少包含 inbox 或 kept"); + const settings = await patch(`${inspirationApiPrefix}/flow/settings`, { + expectedVersion: inspirationInteger(opts.version, "--version", 1), + enabled: inspirationBoolean(opts.enabled, "--enabled"), + intervalMinutes: inspirationInteger(opts.intervalMinutes, "--interval-minutes", 1), + quietStartMinute: inspirationMinute(opts.quietStart, "--quiet-start"), + quietEndMinute: inspirationMinute(opts.quietEnd, "--quiet-end"), + cooldownMinutes: inspirationInteger(opts.cooldownMinutes, "--cooldown-minutes"), + dailyLimit: inspirationInteger(opts.dailyLimit, "--daily-limit", 1), + defaultSnoozeMinutes: inspirationInteger( + opts.defaultSnoozeMinutes, + "--default-snooze-minutes", + 1 + ), + statuses, + tags: splitCsv(opts.tags), + projects: splitCsv(opts.projects), + }); + printSuccess(thisCommand, settings, () => { + console.log(`✓ Flow 设置已保存 v${(settings as any).version}`); + }); + }) +); + +withJson( + inspirationFlow + .command("deliveries") + .description("查看 Flow 投递 ledger;不包含灵感正文。") + .option("--limit ", "返回数量,范围 1–100", "20") + .option("--before ", "surfacedAt 游标,ISO 8601 且包含时区") + .addHelpText( + "after", + `\n示例:\n $ el inspiration flow deliveries --limit 20\n $ el inspiration flow deliveries --before 2026-08-24T12:00:00+08:00 --json\n` + ) +).action( + action(async (thisCommand, opts: { limit: string; before?: string }) => { + const params = new URLSearchParams({ + limit: String(inspirationLimit(opts.limit, "--limit")), + }); + if (opts.before) params.set("before", opts.before); + const result = await api(`${inspirationApiPrefix}/flow/deliveries?${params}`); + printSuccess(thisCommand, result, () => { + const deliveries = Array.isArray((result as any).deliveries) + ? (result as any).deliveries + : []; + if (deliveries.length === 0) { + console.log("暂无 Flow 投递"); + return; + } + for (const delivery of deliveries) { + console.log( + `${delivery.id}\tv${delivery.version}\t${delivery.status}\t${delivery.outcome ?? "-"}\t${delivery.surfacedAt}` + ); + } + }); + }) +); + // el screen [date] const screen = program .command("screen") diff --git a/src/core/plugins/registry.ts b/src/core/plugins/registry.ts index 2eca0de..f3e6f4f 100644 --- a/src/core/plugins/registry.ts +++ b/src/core/plugins/registry.ts @@ -1,8 +1,10 @@ import type { PluginDefinition } from "@echolog/plugin-sdk"; +import { inspirationPlugin } from "@echolog/plugin-inspiration"; import { screenTimePlugin } from "@echolog/plugin-screen-time"; import { tmuxStatusPlugin } from "@echolog/plugin-tmux-status"; export const bundledPlugins: readonly PluginDefinition[] = [ + inspirationPlugin, screenTimePlugin, tmuxStatusPlugin, ]; @@ -10,4 +12,7 @@ export const bundledPlugins: readonly PluginDefinition[] = [ export const bundledPluginWebAssets = [{ prefix: "/plugins/screen-time/", root: "screen-time/web", +}, { + prefix: "/plugins/inspiration/", + root: "inspiration/web", }] as const; diff --git a/tests/inspiration-capture.test.ts b/tests/inspiration-capture.test.ts new file mode 100644 index 0000000..929dc01 --- /dev/null +++ b/tests/inspiration-capture.test.ts @@ -0,0 +1,425 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import type { PluginHttpRequest, PluginRoute } from "@echolog/plugin-sdk"; +import manifest from "../plugins/inspiration/echolog.plugin.json" with { type: "json" }; +import { migrations } from "../plugins/inspiration/src/migrations.js"; +import { + createInspirationRoutes, + type InspirationCaptureStore, +} from "../plugins/inspiration/src/routes.js"; +import { + InspirationStoreError, + type InspirationPage, + type InspirationStoreListFilter, +} from "../plugins/inspiration/src/store.js"; +import type { + CreateInspirationInput, + Inspiration, + InspirationStatus, + UpdateInspirationInput, +} from "../plugins/inspiration/src/types.js"; + +const now = new Date("2026-08-24T01:00:00.000Z"); + +function row(overrides: Partial = {}): Inspiration { + return { + id: "capture_001", + version: 1, + content: "A durable idea", + tags: ["design"], + project: "EchoLog", + status: "inbox", + createdAt: now, + updatedAt: now, + archivedAt: null, + lastSurfacedAt: null, + ...overrides, + }; +} + +class MemoryCaptureStore implements InspirationCaptureStore { + readonly rows = new Map(); + lastCreate: CreateInspirationInput | null = null; + lastFilter: InspirationStoreListFilter | null = null; + + async create(input: CreateInspirationInput): Promise { + this.lastCreate = input; + const created = row({ + id: `capture_${String(this.rows.size + 1).padStart(3, "0")}`, + content: input.content, + tags: input.tags, + project: input.project, + status: input.status, + }); + this.rows.set(created.id, created); + return created; + } + + async get(id: string): Promise { + return this.rows.get(id) ?? null; + } + + async list(filter: InspirationStoreListFilter): Promise { + this.lastFilter = filter; + const items = [...this.rows.values()] + .filter((item) => filter.includeArchived || item.status !== "archived") + .filter((item) => !filter.statuses?.length || filter.statuses.includes(item.status)) + .filter((item) => filter.project === undefined || item.project === filter.project) + .filter((item) => !filter.tags?.length || filter.tags.every((tag) => item.tags.includes(tag))) + .filter((item) => !filter.text || item.content.toLowerCase().includes(filter.text.toLowerCase())) + .filter((item) => !filter.before || item.createdAt < filter.before) + .filter((item) => !filter.after || item.createdAt > filter.after) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) + .slice(0, filter.limit); + return { items, nextCursor: null }; + } + + async update(id: string, input: UpdateInspirationInput): Promise { + const current = this.requireCurrent(id, input.expectedVersion); + if (current.status === "archived") { + throw new InspirationStoreError( + "INSPIRATION_INVALID_STATE", + `Inspiration ${id} cannot be updated from status archived`, + 409, + current.version + ); + } + const updated = { + ...current, + ...(input.content === undefined ? {} : { content: input.content }), + ...(input.tags === undefined ? {} : { tags: input.tags }), + ...(input.project === undefined ? {} : { project: input.project }), + ...(input.status === undefined ? {} : { status: input.status }), + version: current.version + 1, + updatedAt: new Date(now.getTime() + current.version), + }; + this.rows.set(id, updated); + return updated; + } + + async archive(id: string, expectedVersion: number): Promise { + const current = this.requireCurrent(id, expectedVersion); + if (current.status === "archived") { + throw new InspirationStoreError( + "INSPIRATION_INVALID_STATE", + `Inspiration ${id} cannot be archived from status archived`, + 409, + current.version + ); + } + const archived = { + ...current, + version: current.version + 1, + status: "archived" as const, + archivedAt: now, + updatedAt: now, + }; + this.rows.set(id, archived); + return archived; + } + + async restore( + id: string, + expectedVersion: number, + status: Exclude + ): Promise { + const current = this.requireCurrent(id, expectedVersion); + if (current.status !== "archived") { + throw new InspirationStoreError( + "INSPIRATION_INVALID_STATE", + `Inspiration ${id} cannot be restored from status ${current.status}`, + 409, + current.version + ); + } + const restored = { + ...current, + version: current.version + 1, + status, + archivedAt: null, + updatedAt: now, + }; + this.rows.set(id, restored); + return restored; + } + + private requireCurrent(id: string, expectedVersion: number): Inspiration { + const current = this.rows.get(id); + if (!current) { + throw new InspirationStoreError( + "INSPIRATION_NOT_FOUND", + `Inspiration ${id} not found`, + 404 + ); + } + if (current.version !== expectedVersion) { + throw new InspirationStoreError( + "INSPIRATION_VERSION_CONFLICT", + `Inspiration ${id} has changed`, + 409, + current.version + ); + } + return current; + } +} + +function route( + routes: PluginRoute[], + method: PluginRoute["method"], + path: string +): PluginRoute { + const found = routes.find((candidate) => + candidate.method === method && candidate.path === path + ); + assert.ok(found, `${method} ${path} route is registered`); + return found; +} + +async function call( + handler: PluginRoute["handler"], + partial: Partial = {} +): Promise { + return handler({ + params: {}, + query: {}, + body: undefined, + headers: {}, + ...partial, + }, new AbortController().signal); +} + +test("manifest and migrations define one private standalone plugin schema", () => { + assert.equal(manifest.id, "inspiration"); + assert.deepEqual(manifest.permissions, ["database:plugin"]); + assert.deepEqual(migrations.map((migration) => migration.name), [ + "001_inspirations", + "002_inspiration_flow_settings", + "003_inspiration_flow_deliveries", + "004_inspiration_flow_delivery_attempts", + ]); + const sql = migrations.map((migration) => migration.sql).join("\n"); + assert.match(sql, /CREATE TABLE IF NOT EXISTS inspirations/); + assert.match(sql, /CREATE TABLE IF NOT EXISTS inspiration_flow_settings/); + assert.match(sql, /CREATE TABLE IF NOT EXISTS inspiration_flow_deliveries/); + assert.match(sql, /dedupe_key[\s\S]*CREATE UNIQUE INDEX/); + assert.match(sql, /inspiration_id TEXT NOT NULL REFERENCES inspirations\(id\)/); + assert.match(sql, /CHECK \(\(status = 'archived'\) = \(archived_at IS NOT NULL\)\)/); + assert.doesNotMatch(sql, /REFERENCES\s+(records|tasks|schedule)/i); + assert.doesNotMatch(sql, /\/api\/(schedule|records)/i); +}); + +test("capture validates and normalizes input without requiring Core state", async () => { + const store = new MemoryCaptureStore(); + const routes = createInspirationRoutes(() => store); + const result = await call( + route(routes, "POST", "/api/plugins/inspiration/inspirations").handler, + { + body: { + content: " Build a quiet inbox ", + tags: [" Product ", "product", "Ideas"], + project: " EchoLog ", + }, + } + ); + assert.equal(result.statusCode, 201); + assert.equal(result.body.content, "Build a quiet inbox"); + assert.deepEqual(result.body.tags, ["ideas", "product"]); + assert.deepEqual(store.lastCreate, { + content: "Build a quiet inbox", + tags: ["ideas", "product"], + project: "EchoLog", + status: "inbox", + }); +}); + +test("capture routes reject unknown fields and invalid archived creation", async () => { + const routes = createInspirationRoutes(() => new MemoryCaptureStore()); + const handler = route( + routes, + "POST", + "/api/plugins/inspiration/inspirations" + ).handler; + const unknown = await call(handler, { + body: { content: "Idea", scheduleId: "outside-scope" }, + }); + assert.equal(unknown.statusCode, 400); + assert.equal(unknown.body.code, "INSPIRATION_VALIDATION_ERROR"); + const archived = await call(handler, { + body: { content: "Idea", status: "archived" }, + }); + assert.equal(archived.statusCode, 400); +}); + +test("list normalizes filters and preserves deterministic history contract", async () => { + const store = new MemoryCaptureStore(); + store.rows.set("capture_001", row()); + store.rows.set("capture_002", row({ + id: "capture_002", + content: "Another FLOW thought", + tags: ["flow", "product"], + status: "kept", + createdAt: new Date("2026-08-24T02:00:00.000Z"), + })); + store.rows.set("capture_003", row({ + id: "capture_003", + status: "archived", + archivedAt: now, + })); + const routes = createInspirationRoutes(() => store); + const handler = route( + routes, + "GET", + "/api/plugins/inspiration/inspirations" + ).handler; + const result = await call(handler, { + query: { + text: "flow", + tag: [" Product ", "flow"], + project: "EchoLog", + status: ["inbox", "kept"], + includeArchived: "false", + createdAfter: "2026-08-24T00:00:00.000Z", + createdBefore: "2026-08-25T00:00:00.000Z", + limit: "10", + }, + }); + assert.deepEqual(result.items.map((item: Inspiration) => item.id), ["capture_002"]); + assert.deepEqual(store.lastFilter, { + text: "flow", + tags: ["flow", "product"], + project: "EchoLog", + statuses: ["inbox", "kept"], + includeArchived: false, + limit: 10, + before: new Date("2026-08-25T00:00:00.000Z"), + after: new Date("2026-08-24T00:00:00.000Z"), + }); +}); + +test("opaque cursor preserves timestamp and id tie-break boundary", async () => { + const store = new MemoryCaptureStore(); + const routes = createInspirationRoutes(() => store); + const cursor = Buffer.from(JSON.stringify({ + createdAt: "2026-08-24T01:00:00.000Z", + id: "capture_009", + })).toString("base64url"); + const result = await call( + route(routes, "GET", "/api/plugins/inspiration/inspirations").handler, + { query: { cursor, limit: "25" } } + ); + assert.deepEqual(result, { items: [], nextCursor: null }); + assert.equal(store.lastFilter?.before?.toISOString(), "2026-08-24T01:00:00.000Z"); + assert.equal(store.lastFilter?.beforeId, "capture_009"); + const invalid = await call( + route(routes, "GET", "/api/plugins/inspiration/inspirations").handler, + { query: { cursor: "not-a-cursor" } } + ); + assert.equal(invalid.statusCode, 400); +}); + +test("version-guarded edits reject stale concurrent updates", async () => { + const store = new MemoryCaptureStore(); + store.rows.set("capture_001", row()); + const routes = createInspirationRoutes(() => store); + const handler = route( + routes, + "PATCH", + "/api/plugins/inspiration/inspirations/:id" + ).handler; + const first = await call(handler, { + params: { id: "capture_001" }, + body: { expectedVersion: 1, content: "First writer" }, + }); + assert.equal(first.version, 2); + const stale = await call(handler, { + params: { id: "capture_001" }, + body: { expectedVersion: 1, content: "Stale writer" }, + }); + assert.equal(stale.statusCode, 409); + assert.deepEqual(stale.body, { + error: "Inspiration capture_001 has changed", + code: "INSPIRATION_VERSION_CONFLICT", + currentVersion: 2, + }); + assert.equal(store.rows.get("capture_001")?.content, "First writer"); + + const source = readFileSync( + new URL("../plugins/inspiration/src/store.ts", import.meta.url), + "utf8" + ); + assert.match(source, /eq\(inspirations\.version, input\.expectedVersion\)/); + assert.match(source, /eq\(inspirations\.version, expectedVersion\)/); + assert.match(source, /version: sql`\$\{inspirations\.version\} \+ 1`/); +}); + +test("archive and restore are explicit versioned lifecycle operations", async () => { + const store = new MemoryCaptureStore(); + store.rows.set("capture_001", row({ status: "kept" })); + const routes = createInspirationRoutes(() => store); + const archived = await call( + route( + routes, + "POST", + "/api/plugins/inspiration/inspirations/:id/archive" + ).handler, + { params: { id: "capture_001" }, body: { expectedVersion: 1 } } + ); + assert.equal(archived.status, "archived"); + assert.equal(archived.version, 2); + assert.ok(archived.archivedAt); + + const restored = await call( + route( + routes, + "POST", + "/api/plugins/inspiration/inspirations/:id/restore" + ).handler, + { + params: { id: "capture_001" }, + body: { expectedVersion: 2, status: "kept" }, + } + ); + assert.equal(restored.status, "kept"); + assert.equal(restored.version, 3); + assert.equal(restored.archivedAt, null); +}); + +test("get, update, archive, and restore return structured missing/state errors", async () => { + const store = new MemoryCaptureStore(); + store.rows.set("capture_001", row()); + const routes = createInspirationRoutes(() => store); + const missing = await call( + route(routes, "GET", "/api/plugins/inspiration/inspirations/:id").handler, + { params: { id: "missing_001" } } + ); + assert.equal(missing.statusCode, 404); + assert.equal(missing.body.code, "INSPIRATION_NOT_FOUND"); + + const invalidRestore = await call( + route( + routes, + "POST", + "/api/plugins/inspiration/inspirations/:id/restore" + ).handler, + { params: { id: "capture_001" }, body: { expectedVersion: 1 } } + ); + assert.equal(invalidRestore.statusCode, 409); + assert.equal(invalidRestore.body.code, "INSPIRATION_INVALID_STATE"); + assert.equal(invalidRestore.body.currentVersion, 1); +}); + +test("store query source implements every Capture filter without cross-plugin access", () => { + const source = readFileSync( + new URL("../plugins/inspiration/src/store.ts", import.meta.url), + "utf8" + ); + assert.match(source, /ILIKE/); + assert.match(source, /arrayContains\(inspirations\.tags/); + assert.match(source, /eq\(inspirations\.project/); + assert.match(source, /inArray\(inspirations\.status/); + assert.match(source, /ne\(inspirations\.status, "archived"\)/); + assert.match(source, /orderBy\(desc\(inspirations\.createdAt\), desc\(inspirations\.id\)\)/); + assert.doesNotMatch(source, /\/api\/(schedule|records)|from\((records|tasks)\)/i); +}); diff --git a/tests/inspiration-clients.test.ts b/tests/inspiration-clients.test.ts new file mode 100644 index 0000000..3fce375 --- /dev/null +++ b/tests/inspiration-clients.test.ts @@ -0,0 +1,467 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, type IncomingMessage } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + inspirationCliContribution, + renderInspirationDailySummary, +} from "../plugins/inspiration/src/cli.js"; +import { createPluginWebHost } from "../web/plugin-host.js"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const webModulePath = new URL("../plugins/inspiration/web/index.js", import.meta.url).href; + +function escapeText(value: unknown): string { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function escapeAttribute(value: unknown): string { + return escapeText(value).replaceAll("'", "'"); +} + +function runCli(configPath: string, args: string[]): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return new Promise((resolve) => { + execFile( + join(repoRoot, "node_modules/.bin/tsx"), + [join(repoRoot, "src/cli/index.ts"), ...args], + { + cwd: repoRoot, + env: { ...process.env, ECHOLOG_CONFIG_PATH: configPath }, + }, + (error, stdout, stderr) => resolve({ + exitCode: typeof error?.code === "number" ? error.code : 0, + stdout, + stderr, + }) + ); + }); +} + +async function requestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const text = Buffer.concat(chunks).toString("utf8"); + return text ? JSON.parse(text) : null; +} + +test("Inspiration CLI metadata and daily summary stay aggregate-only", () => { + assert.deepEqual(inspirationCliContribution, { + command: "inspiration", + apiPrefix: "/api/plugins/inspiration", + }); + assert.equal(renderInspirationDailySummary({ captured: 0, surfaced: 0, outcomes: {} }), null); + const summary = renderInspirationDailySummary({ + captured: 2, + surfaced: 3, + outcomes: { viewed: 1, later: 2 }, + }); + assert.equal(summary, "捕捉 2 条,Flow 浮现 3 次。\n结果:查看 1、稍后 2。"); + assert.equal(summary?.includes("private inspiration body"), false); +}); + +test("Inspiration CLI is HTTP-thin and preserves raw JSON success and errors", async () => { + const calls: Array<{ method: string; url: string; body: unknown }> = []; + const captured = { + id: "inspiration-1", + version: 1, + content: "试试更短的 onboarding", + tags: ["product", "ux"], + project: null, + status: "inbox", + createdAt: "2026-08-24T04:00:00.000Z", + updatedAt: "2026-08-24T04:00:00.000Z", + archivedAt: null, + lastSurfacedAt: null, + }; + const conflict = { + error: "inspiration version conflict", + code: "VERSION_CONFLICT", + currentVersion: 2, + }; + const disabled = { + error: "Plugin inspiration is disabled", + code: "PLUGIN_DISABLED", + pluginId: "inspiration", + }; + const server = createServer(async (request, response) => { + const body = await requestBody(request); + calls.push({ method: request.method ?? "", url: request.url ?? "", body }); + if (request.url?.endsWith("/conflict")) { + response.writeHead(409, { "content-type": "application/json" }); + response.end(JSON.stringify(conflict)); + return; + } + if (request.url === "/api/plugins/inspiration/flow/settings") { + response.writeHead(503, { "content-type": "application/json" }); + response.end(JSON.stringify(disabled)); + return; + } + if (request.url === "/api/plugins/inspiration/flow/next") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ candidate: null, explanation: ["daily-limit"] })); + return; + } + response.writeHead(201, { "content-type": "application/json" }); + response.end(JSON.stringify(captured)); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const temporary = await mkdtemp(join(tmpdir(), "echolog-inspiration-cli-")); + const configPath = join(temporary, "config.yaml"); + await writeFile(configPath, `server:\n port: ${address.port}\n host: localhost\n`); + + try { + const capture = await runCli(configPath, [ + "--json", + "inspiration", + "capture", + captured.content, + "--tags", + "product,ux", + ]); + assert.equal(capture.exitCode, 0); + assert.equal(capture.stderr, ""); + assert.deepEqual(JSON.parse(capture.stdout), captured); + assert.deepEqual(calls[0], { + method: "POST", + url: "/api/plugins/inspiration/inspirations", + body: { + content: captured.content, + tags: ["product", "ux"], + project: null, + status: "inbox", + }, + }); + + const next = await runCli(configPath, [ + "inspiration", + "flow", + "next", + "--idempotency-key", + "manual-test", + "--json", + ]); + assert.equal(next.exitCode, 0); + assert.deepEqual(JSON.parse(next.stdout), { + candidate: null, + explanation: ["daily-limit"], + }); + assert.deepEqual(calls[1], { + method: "POST", + url: "/api/plugins/inspiration/flow/next", + body: { idempotencyKey: "manual-test" }, + }); + + const failed = await runCli(configPath, [ + "inspiration", + "show", + "conflict", + "--json", + ]); + assert.equal(failed.exitCode, 1); + assert.equal(failed.stdout, ""); + assert.deepEqual(JSON.parse(failed.stderr), conflict); + + const unavailable = await runCli(configPath, [ + "inspiration", + "flow", + "settings", + "--json", + ]); + assert.equal(unavailable.exitCode, 1); + assert.equal(unavailable.stdout, ""); + assert.deepEqual(JSON.parse(unavailable.stderr), disabled); + + const help = await runCli(configPath, ["inspiration", "flow", "outcome", "--help"]); + assert.equal(help.exitCode, 0); + assert.match(help.stdout, /viewed \| continued \| kept \| later \| archived/); + assert.match(help.stdout, /--delivery-version/); + assert.match(help.stdout, /--inspiration-version/); + } finally { + await new Promise((resolve, reject) => server.close((error) => + error ? reject(error) : resolve() + )); + await rm(temporary, { recursive: true, force: true }); + } +}); + +test("Inspiration Web contributes only while ready", async () => { + let state = "disabled"; + const host = createPluginWebHost(async (path: string) => { + assert.equal(path, "/plugins"); + return { + plugins: [{ + id: "inspiration", + enabled: state !== "disabled", + state, + webEntry: webModulePath, + }], + }; + }); + const api = async () => ({ items: [] }); + + await host.refresh({ api }); + assert.deepEqual(host.faces(), []); + state = "degraded"; + await host.refresh({ api }); + assert.deepEqual(host.faces(), []); + state = "ready"; + await host.refresh({ api }); + assert.deepEqual(host.faces(), [ + { type: "inspiration-inbox" }, + { type: "inspiration-flow" }, + ]); + state = "disabled"; + await host.refresh({ api }); + assert.deepEqual(host.faces(), []); + await host.stop(); +}); + +test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow policy", async () => { + const { activate } = await import(webModulePath); + const malicious = ''; + const inspiration = { + id: "inspiration-1", + version: 3, + content: malicious, + tags: [""], + project: 'Project "quoted"', + status: "inbox", + createdAt: "2026-08-24T04:00:00.000Z", + updatedAt: "2026-08-24T04:00:00.000Z", + archivedAt: null, + lastSurfacedAt: null, + }; + const delivery = { + id: "delivery-1", + version: 1, + attempts: 1, + inspirationId: inspiration.id, + source: "manual", + dedupeKey: "manual:test", + status: "sent", + outcome: null, + surfacedAt: "2026-08-24T05:00:00.000Z", + notifiedAt: null, + snoozedUntil: null, + outcomeAt: null, + notificationChannel: null, + error: null, + createdAt: "2026-08-24T05:00:00.000Z", + updatedAt: "2026-08-24T05:00:00.000Z", + }; + const settings = { + id: "default", + version: 2, + enabled: true, + intervalMinutes: 180, + quietStartMinute: 1320, + quietEndMinute: 480, + cooldownMinutes: 1440, + dailyLimit: 3, + defaultSnoozeMinutes: 120, + statuses: ["inbox", "kept"], + tags: [], + projects: [], + updatedAt: "2026-08-24T04:00:00.000Z", + }; + const calls: Array<{ path: string; options?: { method?: string; body?: string } }> = []; + const api = async (path: string, options?: { method?: string; body?: string }) => { + calls.push({ path, options }); + if (path.includes("/inspirations?") && !options) { + return { items: [inspiration], nextCursor: null }; + } + if (path.endsWith("/flow/settings") && !options) return settings; + if (path.includes("/flow/deliveries?") && !options) return { deliveries: [delivery] }; + if (path.endsWith("/flow/next")) { + return { + candidate: { + inspiration, + delivery, + explanation: ["never surfaced", malicious], + duplicate: false, + }, + explanation: [], + }; + } + return { ...inspiration, version: inspiration.version + 1 }; + }; + const contribution = await activate({ api }); + const data = await contribution.load(); + assert.deepEqual(calls.slice(0, 3).map((call) => call.path), [ + "/plugins/inspiration/inspirations?limit=50&status=inbox&status=kept", + "/plugins/inspiration/flow/settings", + "/plugins/inspiration/flow/deliveries?limit=20", + ]); + assert.deepEqual(Object.keys(data).sort(), [ + "inspirationFlowDeliveries", + "inspirationFlowSettings", + "inspirationList", + ]); + + const inboxHtml = contribution.renderFace( + { type: "inspiration-inbox" }, + { data, esc: escapeText, escA: escapeAttribute } + ); + assert.equal(inboxHtml.includes(malicious), false); + assert.equal(inboxHtml.includes(""), false); + assert.match(inboxHtml, /<img src=x onerror="alert\(1\)">/); + assert.match(inboxHtml, /<script>alert\(2\)<\/script>/); + + const elements: Record = { + inspirationNewContent: { value: "new idea" }, + inspirationNewTags: { value: "Product, UX" }, + inspirationNewProject: { value: "EchoLog" }, + inspirationNewError: { textContent: "" }, + }; + const $ = (id: string) => elements[id] ?? null; + const captureResult = await contribution.handleAction("capture-inspiration", { id: undefined, $ }); + assert.equal(captureResult.handled, true); + assert.deepEqual(calls.at(-1), { + path: "/plugins/inspiration/inspirations", + options: { + method: "POST", + body: JSON.stringify({ + content: "new idea", + tags: ["Product", "UX"], + project: "EchoLog", + status: "inbox", + }), + }, + }); + + Object.assign(elements, { + "inspirationContent:inspiration-1": { value: "edited idea" }, + "inspirationTags:inspiration-1": { value: "edited,idea" }, + "inspirationProject:inspiration-1": { value: "Project B" }, + "inspirationStatus:inspiration-1": { value: "kept" }, + "inspirationError:inspiration-1": { textContent: "" }, + }); + await contribution.handleAction("edit-inspiration", { id: inspiration.id, $ }); + assert.deepEqual(calls.at(-1), { + path: "/plugins/inspiration/inspirations/inspiration-1", + options: { + method: "PATCH", + body: JSON.stringify({ + expectedVersion: 3, + content: "edited idea", + tags: ["edited", "idea"], + project: "Project B", + status: "kept", + }), + }, + }); + await contribution.handleAction("archive-inspiration", { id: inspiration.id, $ }); + assert.deepEqual(calls.at(-1), { + path: "/plugins/inspiration/inspirations/inspiration-1/archive", + options: { + method: "POST", + body: JSON.stringify({ expectedVersion: 3 }), + }, + }); + + Object.assign(elements, { + inspirationFilterText: { value: "edited" }, + inspirationFilterTags: { value: "ux, Product" }, + inspirationFilterProject: { value: "EchoLog" }, + inspirationFilterInbox: { checked: false }, + inspirationFilterKept: { checked: true }, + inspirationFilterArchived: { checked: true }, + inspirationIncludeArchived: { checked: true }, + }); + await contribution.handleAction("filter-inspirations", { id: undefined, $ }); + await contribution.load(); + assert.equal( + calls.at(-3)?.path, + "/plugins/inspiration/inspirations?limit=50&text=edited&tag=ux&tag=Product&project=EchoLog&status=kept&status=archived&includeArchived=true" + ); + + Object.assign(elements, { + inspirationFlowEnabled: { checked: false }, + inspirationFlowInterval: { value: "240" }, + inspirationFlowQuietStart: { value: "23:00" }, + inspirationFlowQuietEnd: { value: "07:30" }, + inspirationFlowCooldown: { value: "720" }, + inspirationFlowDailyLimit: { value: "4" }, + inspirationFlowDefaultSnooze: { value: "60" }, + inspirationFlowStatusInbox: { checked: true }, + inspirationFlowStatusKept: { checked: false }, + inspirationFlowTags: { value: "ux, product" }, + inspirationFlowProjects: { value: "EchoLog" }, + inspirationSettingsError: { textContent: "" }, + }); + await contribution.handleAction("save-inspiration-settings", { id: undefined, $ }); + assert.deepEqual(calls.at(-1), { + path: "/plugins/inspiration/flow/settings", + options: { + method: "PATCH", + body: JSON.stringify({ + expectedVersion: 2, + enabled: false, + intervalMinutes: 240, + quietStartMinute: 1380, + quietEndMinute: 450, + cooldownMinutes: 720, + dailyLimit: 4, + defaultSnoozeMinutes: 60, + statuses: ["inbox"], + tags: ["ux", "product"], + projects: ["EchoLog"], + }), + }, + }); + + await contribution.handleAction("next-inspiration", { id: undefined, $ }); + const flowHtml = contribution.renderFace( + { type: "inspiration-flow" }, + { data, esc: escapeText, escA: escapeAttribute } + ); + assert.equal(flowHtml.includes(malicious), false); + assert.match(flowHtml, /<img src=x onerror="alert\(1\)">/); + elements.inspirationSnooze = { value: "90" }; + elements.inspirationFlowError = { textContent: "" }; + const outcomeResult = await contribution.handleAction("inspiration-outcome-later", { + id: delivery.id, + $, + }); + assert.equal(outcomeResult.handled, true); + assert.deepEqual(calls.at(-1), { + path: "/plugins/inspiration/flow/deliveries/delivery-1/outcome", + options: { + method: "POST", + body: JSON.stringify({ + expectedDeliveryVersion: 1, + expectedInspirationVersion: 3, + outcome: "later", + snoozeMinutes: 90, + }), + }, + }); + assert.equal(calls.every((call) => call.path.startsWith("/plugins/inspiration")), true); +}); + +test("Inspiration client sources contain no scheduling conversion surface", async () => { + const web = await readFile(join(repoRoot, "plugins/inspiration/web/index.js"), "utf8"); + const pluginCli = await readFile(join(repoRoot, "plugins/inspiration/src/cli.ts"), "utf8"); + const rootCli = await readFile(join(repoRoot, "src/cli/index.ts"), "utf8"); + const start = rootCli.indexOf("type InspirationLifecycleStatus"); + const end = rootCli.indexOf("// el screen [date]", start); + assert.ok(start >= 0 && end > start); + const inspirationRegistration = rootCli.slice(start, end); + for (const source of [web, pluginCli, inspirationRegistration]) { + assert.doesNotMatch(source, /schedule|转为日程|安排日程|日程 API/i); + } +}); diff --git a/tests/inspiration-flow.test.ts b/tests/inspiration-flow.test.ts new file mode 100644 index 0000000..60dedf9 --- /dev/null +++ b/tests/inspiration-flow.test.ts @@ -0,0 +1,510 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { PluginHttpRequest } from "@echolog/plugin-sdk"; +import { createFlowRoutes, validateOutcome, validateSettingsUpdate } from "../plugins/inspiration/src/flow-routes.js"; +import { FlowStoreError, type FlowOutcomeResult, type FlowReserveResult } from "../plugins/inspiration/src/flow-store.js"; +import { + createFlowJob, + FlowService, + scheduledFlowDedupeKey, + type FlowPersistence, +} from "../plugins/inspiration/src/flow.js"; +import { + candidateExclusionReasons, + isQuietMinute, + selectFlowCandidate, + type SelectableInspiration, +} from "../plugins/inspiration/src/selector.js"; +import type { + FlowCandidate, + FlowDelivery, + FlowSettings, + Inspiration, +} from "../plugins/inspiration/src/types.js"; + +const NOW = new Date("2026-08-24T12:00:00.000Z"); + +function inspiration( + overrides: Partial = {} +): Inspiration { + return { + id: "idea-a", + version: 1, + content: "Build a deterministic inspiration flow", + tags: ["product"], + project: "echolog", + status: "inbox", + createdAt: new Date("2026-08-01T00:00:00.000Z"), + updatedAt: new Date("2026-08-01T00:00:00.000Z"), + archivedAt: null, + lastSurfacedAt: null, + ...overrides, + }; +} + +function settings(overrides: Partial = {}): FlowSettings { + return { + id: "default", + version: 1, + enabled: true, + intervalMinutes: 60, + quietStartMinute: 0, + quietEndMinute: 0, + cooldownMinutes: 60, + dailyLimit: 3, + defaultSnoozeMinutes: 120, + statuses: ["inbox", "kept"], + tags: [], + projects: [], + updatedAt: NOW, + ...overrides, + }; +} + +function delivery(overrides: Partial = {}): FlowDelivery { + return { + id: "delivery-a", + version: 1, + attempts: 1, + inspirationId: "idea-a", + source: "manual", + dedupeKey: "manual:request-a", + status: "reserved", + outcome: null, + surfacedAt: NOW, + notifiedAt: null, + snoozedUntil: null, + outcomeAt: null, + notificationChannel: null, + error: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function candidate(overrides: Partial = {}): FlowCandidate { + return { + inspiration: inspiration({ version: 2, lastSurfacedAt: NOW }), + delivery: delivery(), + explanation: ["selection:never-surfaced-first"], + duplicate: false, + ...overrides, + }; +} + +function selectable( + inspirationOverrides: Partial = {}, + snoozedUntil: Date | null = null +): SelectableInspiration { + return { inspiration: inspiration(inspirationOverrides), snoozedUntil }; +} + +function reserveResult(value = candidate()): FlowReserveResult { + return { + candidate: value, + explanation: value.explanation, + shouldNotify: value.delivery.status === "reserved", + }; +} + +function outcomeResult(): FlowOutcomeResult { + return { + delivery: delivery({ version: 2, status: "acted", outcome: "later" }), + inspiration: inspiration({ version: 2, lastSurfacedAt: NOW }), + }; +} + +function persistence( + overrides: Partial = {} +): FlowPersistence { + return { + async getSettings() { + return settings(); + }, + async updateSettings() { + return settings({ version: 2 }); + }, + async reserveNext() { + return reserveResult(); + }, + async finalizeNotification(_id, _version, result) { + return result.delivered + ? delivery({ + version: 2, + status: "sent", + notifiedAt: result.at, + notificationChannel: result.channel, + }) + : delivery({ version: 2, status: "failed", error: result.error }); + }, + async listDeliveries() { + return []; + }, + async applyOutcome() { + return outcomeResult(); + }, + async getDailySummary() { + return { captured: 0, surfaced: 0, outcomes: {} }; + }, + ...overrides, + }; +} + +test("quiet-hour policy handles daytime, overnight, and disabled ranges", () => { + assert.equal(isQuietMinute(10 * 60, 9 * 60, 17 * 60), true); + assert.equal(isQuietMinute(18 * 60, 9 * 60, 17 * 60), false); + assert.equal(isQuietMinute(23 * 60, 22 * 60, 8 * 60), true); + assert.equal(isQuietMinute(7 * 60 + 59, 22 * 60, 8 * 60), true); + assert.equal(isQuietMinute(8 * 60, 22 * 60, 8 * 60), false); + assert.equal(isQuietMinute(12 * 60, 0, 0), false); +}); + +test("manual and scheduled Flow use identical candidate ranking", () => { + const candidates = [ + selectable({ + id: "surfaced", + createdAt: new Date("2026-07-01T00:00:00.000Z"), + lastSurfacedAt: new Date("2026-08-01T00:00:00.000Z"), + }), + selectable({ + id: "never-b", + createdAt: new Date("2026-08-02T00:00:00.000Z"), + }), + selectable({ + id: "never-a", + createdAt: new Date("2026-08-02T00:00:00.000Z"), + }), + ]; + const common = { + candidates, + settings: settings(), + now: NOW, + surfacedToday: 0, + }; + assert.equal( + selectFlowCandidate({ ...common, source: "manual" }).selected?.inspiration.id, + "never-a" + ); + assert.equal( + selectFlowCandidate({ ...common, source: "scheduled" }).selected?.inspiration.id, + "never-a" + ); +}); + +test("scheduled gates enabled and overnight quiet hours while manual bypasses only those gates", () => { + const localNow = new Date(2026, 7, 24, 23, 30); + const common = { + candidates: [selectable()], + settings: settings({ + enabled: false, + quietStartMinute: 22 * 60, + quietEndMinute: 8 * 60, + }), + now: localNow, + surfacedToday: 0, + }; + assert.deepEqual( + selectFlowCandidate({ ...common, source: "scheduled" }).explanation, + ["policy:disabled", "policy:quiet-hours"] + ); + assert.equal( + selectFlowCandidate({ ...common, source: "manual" }).selected?.inspiration.id, + "idea-a" + ); +}); + +test("daily limit applies to both sources", () => { + for (const source of ["manual", "scheduled"] as const) { + const result = selectFlowCandidate({ + candidates: [selectable()], + settings: settings({ dailyLimit: 2 }), + source, + now: NOW, + surfacedToday: 2, + }); + assert.equal(result.selected, null); + assert.deepEqual(result.explanation, ["policy:daily-limit"]); + } +}); + +test("selector explains lifecycle, filter, snooze, and cooldown exclusions", () => { + assert.deepEqual( + candidateExclusionReasons( + selectable( + { + status: "archived", + archivedAt: NOW, + tags: ["other"], + project: "other", + lastSurfacedAt: new Date(NOW.getTime() - 10 * 60_000), + }, + new Date(NOW.getTime() + 60_000) + ), + settings({ tags: ["product"], projects: ["echolog"] }), + NOW + ), + [ + "lifecycle:archived", + "filter:tags", + "filter:project", + `delivery:snoozed-until:${new Date(NOW.getTime() + 60_000).toISOString()}`, + `policy:cooldown-until:${new Date(NOW.getTime() + 50 * 60_000).toISOString()}`, + ] + ); + + const selection = selectFlowCandidate({ + candidates: [selectable({ status: "archived", archivedAt: NOW })], + settings: settings(), + source: "manual", + now: NOW, + surfacedToday: 0, + }); + assert.deepEqual(selection.explanation, [ + "selection:no-eligible-inspirations", + "excluded:idea-a:lifecycle:archived", + ]); +}); + +test("scheduled bucket keys are stable across repeated polls and vary by interval", () => { + const withinBucket = new Date(NOW.getTime() + 30_000); + assert.equal( + scheduledFlowDedupeKey(NOW, 60), + scheduledFlowDedupeKey(withinBucket, 60) + ); + assert.notEqual( + scheduledFlowDedupeKey(NOW, 60), + scheduledFlowDedupeKey(NOW, 30) + ); +}); + +test("scheduled job is bounded and forwards the Host abort signal", async () => { + const observed: AbortSignal[] = []; + const service = { + async runScheduled(signal: AbortSignal) { + observed.push(signal); + return { candidate: null, explanation: [] }; + }, + } as unknown as FlowService; + const job = createFlowJob(service); + const controller = new AbortController(); + await job.run(controller.signal); + assert.equal(job.id, "inspiration-flow"); + assert.equal(job.intervalMs, 60_000); + assert.equal(job.timeoutMs, 30_000); + assert.deepEqual(observed, [controller.signal]); +}); + +test("service sends the narrow notification contract and finalizes the ledger", async () => { + const finalized: unknown[] = []; + const sent: unknown[] = []; + const store = persistence({ + async finalizeNotification(...args) { + finalized.push(args); + return delivery({ version: 2, status: "sent", notifiedAt: NOW }); + }, + }); + const service = new FlowService( + store, + () => ({ + async send(input) { + sent.push(input); + return { delivered: true, channel: "local" }; + }, + }), + () => NOW + ); + const result = await service.nextManual("request-a"); + assert.equal(result.candidate?.delivery.status, "sent"); + assert.deepEqual(sent, [{ + title: "Inspiration", + body: "Build a deterministic inspiration flow", + dedupeKey: "manual:request-a", + data: { + pluginId: "inspiration", + inspirationId: "idea-a", + deliveryId: "delivery-a", + }, + }]); + assert.equal(finalized.length, 1); + assert.deepEqual((finalized[0] as unknown[]).slice(0, 2), ["delivery-a", 1]); +}); + +test("reserved duplicate resumes after restart but sent duplicate is not re-sent", async () => { + let sends = 0; + let state: "reserved" | "sent" = "reserved"; + const store = persistence({ + async reserveNext() { + return reserveResult(candidate({ + duplicate: true, + delivery: delivery({ status: state }), + })); + }, + async finalizeNotification() { + state = "sent"; + return delivery({ version: 2, status: "sent" }); + }, + }); + const service = new FlowService(store, () => ({ + async send() { + sends += 1; + return { delivered: true }; + }, + }), () => NOW); + + await service.nextManual("same-request"); + await service.nextManual("same-request"); + assert.equal(sends, 1); +}); + +test("notification failures are recorded without leaking provider error text", async () => { + let finalization: unknown; + const service = new FlowService(persistence({ + async finalizeNotification(_id, _version, result) { + finalization = result; + return delivery({ version: 2, status: "failed", error: "notifications.send failed" }); + }, + }), () => ({ + async send() { + throw new Error("secret provider response and echoed notification body"); + }, + }), () => NOW); + + const result = await service.nextManual("failed-request"); + assert.equal(result.candidate?.delivery.status, "failed"); + assert.deepEqual(finalization, { + delivered: false, + error: "notifications.send failed", + at: NOW, + }); +}); + +test("abort leaves a durable reservation for a later restart", async () => { + let finalized = false; + const controller = new AbortController(); + controller.abort(); + const service = new FlowService(persistence({ + async reserveNext() { + return reserveResult(); + }, + async finalizeNotification() { + finalized = true; + return delivery(); + }, + }), () => ({ + async send() { + assert.fail("notification must not be attempted after abort"); + }, + }), () => NOW); + + await assert.rejects( + service.nextManual("aborted", controller.signal), + (error) => error instanceof Error && error.name === "AbortError" + ); + assert.equal(finalized, false); +}); + +test("later calculates delivery snooze without requesting a lifecycle mutation", async () => { + let call: unknown[] | undefined; + const service = new FlowService(persistence({ + async applyOutcome(...args) { + call = args; + return outcomeResult(); + }, + }), () => ({ async send() { return { delivered: true }; } }), () => NOW); + + await service.applyOutcome("delivery-a", { + expectedDeliveryVersion: 2, + expectedInspirationVersion: 2, + outcome: "later", + snoozeMinutes: 30, + }); + assert.deepEqual(call, [ + "delivery-a", + 2, + 2, + "later", + new Date(NOW.getTime() + 30 * 60_000), + NOW, + ]); +}); + +test("Flow route validators reject schedule actions and stale outcomes map to 409", async () => { + assert.deepEqual(validateOutcome({ + expectedDeliveryVersion: 1, + expectedInspirationVersion: 2, + outcome: "schedule", + }), { + ok: false, + error: "outcome must be viewed, continued, kept, later, or archived", + }); + assert.equal(validateSettingsUpdate({}).ok, false); + + const service = { + async applyOutcome() { + throw new FlowStoreError( + "VERSION_CONFLICT", + "Flow outcome version conflict", + 409, + 3, + 4 + ); + }, + } as unknown as FlowService; + const route = createFlowRoutes(() => service).find( + (item) => item.path.endsWith("/:id/outcome") + )!; + const request: PluginHttpRequest = { + params: { id: "delivery-a" }, + query: {}, + body: { + expectedDeliveryVersion: 2, + expectedInspirationVersion: 2, + outcome: "viewed", + }, + headers: {}, + }; + const result = await route.handler(request, new AbortController().signal); + assert.deepEqual(result, { + statusCode: 409, + body: { + error: "Flow outcome version conflict", + code: "VERSION_CONFLICT", + currentDeliveryVersion: 3, + currentInspirationVersion: 4, + }, + }); +}); + +test("settings validation normalizes tags consistently with Capture", () => { + const result = validateSettingsUpdate({ + expectedVersion: 1, + enabled: true, + intervalMinutes: 60, + quietStartMinute: 1_320, + quietEndMinute: 480, + cooldownMinutes: 60, + dailyLimit: 3, + defaultSnoozeMinutes: 120, + statuses: ["inbox", "kept"], + tags: ["Product", "ECHolog"], + projects: ["EchoLog"], + }); + assert.equal(result.ok, true); + if (result.ok) { + assert.deepEqual(result.value.tags, ["echolog", "product"]); + assert.deepEqual(result.value.projects, ["EchoLog"]); + } +}); + +test("Flow exposes only canonical inspiration plugin routes", () => { + const paths = createFlowRoutes(() => ({} as FlowService)).map((route) => route.path); + assert.deepEqual(paths, [ + "/api/plugins/inspiration/flow/settings", + "/api/plugins/inspiration/flow/settings", + "/api/plugins/inspiration/flow/next", + "/api/plugins/inspiration/flow/deliveries", + "/api/plugins/inspiration/flow/deliveries/:id/outcome", + ]); + assert.equal(paths.some((path) => path.includes("schedule")), false); +}); diff --git a/tests/inspiration.integration.ts b/tests/inspiration.integration.ts new file mode 100644 index 0000000..61fb73e --- /dev/null +++ b/tests/inspiration.integration.ts @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import postgres from "postgres"; +import { FlowStore } from "../plugins/inspiration/src/flow-store.js"; +import { migrations } from "../plugins/inspiration/src/migrations.js"; +import { + InspirationStore, + InspirationStoreError, +} from "../plugins/inspiration/src/store.js"; +import { createPluginMigrationRunner } from "../src/core/plugins/migrations.js"; + +const testDatabaseUrl = process.env.ECHOLOG_TEST_DATABASE_URL; + +function testSchemaName(): string { + return `el_test_inspiration_${process.pid}_${randomUUID() + .replaceAll("-", "") + .slice(0, 12)}`; +} + +function quoteTestSchema(schema: string): string { + if (!/^el_test_inspiration_\d+_[a-f0-9]{12}$/.test(schema)) { + throw new Error("refusing to use a non-test schema"); + } + return `"${schema}"`; +} + +function databaseUrlForSchema(databaseUrl: string, schema: string): string { + const url = new URL(databaseUrl); + url.searchParams.set("options", `-c search_path=${schema}`); + return url.toString(); +} + +test("inspiration integration requires an explicit test database URL", () => { + assert.ok( + testDatabaseUrl, + "set ECHOLOG_TEST_DATABASE_URL to run PostgreSQL integration tests" + ); +}); + +test( + "real PostgreSQL enforces optimistic writes, atomic dedupe, snooze isolation, and cross-bucket recovery", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => { + if (!testDatabaseUrl) return; + + const schema = testSchemaName(); + const quotedSchema = quoteTestSchema(schema); + const scopedDatabaseUrl = databaseUrlForSchema(testDatabaseUrl, schema); + const admin = postgres(testDatabaseUrl, { max: 1 }); + const blocker = postgres(scopedDatabaseUrl, { max: 1 }); + const captureStores: InspirationStore[] = []; + const flowStores: FlowStore[] = []; + let schemaCreated = false; + + try { + await admin.unsafe(`CREATE SCHEMA ${quotedSchema}`); + schemaCreated = true; + const migrationRunner = createPluginMigrationRunner(scopedDatabaseUrl); + await migrationRunner("inspiration", migrations); + await migrationRunner("inspiration", migrations); + + const captureA = new InspirationStore(scopedDatabaseUrl); + const captureB = new InspirationStore(scopedDatabaseUrl); + const flowA = new FlowStore(scopedDatabaseUrl); + const flowB = new FlowStore(scopedDatabaseUrl); + captureStores.push(captureA, captureB); + flowStores.push(flowA, flowB); + + const first = await captureA.create({ + content: "first durable idea", + tags: ["flow"], + project: "EchoLog", + status: "inbox", + }); + const writes = await Promise.allSettled([ + captureA.update(first.id, { + expectedVersion: first.version, + content: "winner A", + }), + captureB.update(first.id, { + expectedVersion: first.version, + content: "winner B", + }), + ]); + assert.equal(writes.filter((result) => result.status === "fulfilled").length, 1); + const rejected = writes.find((result) => result.status === "rejected"); + assert.ok(rejected?.status === "rejected"); + assert.ok(rejected.reason instanceof InspirationStoreError); + assert.equal(rejected.reason.code, "INSPIRATION_VERSION_CONFLICT"); + + await captureA.create({ + content: "second durable idea", + tags: ["flow"], + project: "EchoLog", + status: "inbox", + }); + const initialSettings = await flowA.getSettings(); + const configured = await flowA.updateSettings({ + expectedVersion: initialSettings.version, + enabled: true, + intervalMinutes: 60, + quietStartMinute: 0, + quietEndMinute: 0, + cooldownMinutes: 0, + dailyLimit: 100, + defaultSnoozeMinutes: 120, + statuses: ["inbox", "kept"], + tags: [], + projects: [], + }); + assert.equal(configured?.version, initialSettings.version + 1); + + const manualNow = new Date("2026-08-24T08:00:00.000Z"); + const reservations = await Promise.all([ + flowA.reserveNext("manual", "manual:postgres-race", manualNow), + flowB.reserveNext("manual", "manual:postgres-race", manualNow), + ]); + assert.equal(reservations.filter((result) => result.shouldNotify).length, 1); + assert.equal( + new Set(reservations.map((result) => result.candidate?.delivery.id)).size, + 1 + ); + const owner = reservations.find((result) => result.shouldNotify); + assert.ok(owner?.candidate); + const sent = await flowA.finalizeNotification( + owner.candidate.delivery.id, + owner.candidate.delivery.version, + { delivered: true, channel: "integration", at: manualNow } + ); + assert.equal(sent.status, "sent"); + assert.equal(sent.attempts, 1); + + const statusBeforeLater = owner.candidate.inspiration.status; + const later = await flowA.applyOutcome( + sent.id, + sent.version, + owner.candidate.inspiration.version, + "later", + new Date(manualNow.getTime() + 120 * 60_000), + manualNow + ); + assert.equal(later.delivery.outcome, "later"); + assert.equal(later.inspiration.status, statusBeforeLater); + assert.equal( + later.inspiration.version, + owner.candidate.inspiration.version, + "later must not mutate inspiration lifecycle/version" + ); + + const oldScheduledAt = new Date("2026-08-24T10:00:00.000Z"); + const oldBucket = await flowA.reserveNext( + "scheduled", + "scheduled:60:old-bucket", + oldScheduledAt + ); + assert.equal(oldBucket.shouldNotify, true); + assert.ok(oldBucket.candidate); + assert.equal(oldBucket.candidate.delivery.status, "reserved"); + assert.equal(oldBucket.candidate.delivery.attempts, 1); + + const afterBoundary = new Date("2026-08-24T11:01:00.000Z"); + const recovered = await flowB.reserveNext( + "scheduled", + "scheduled:60:new-bucket", + afterBoundary + ); + assert.equal(recovered.shouldNotify, true); + assert.ok(recovered.candidate); + assert.equal(recovered.candidate.delivery.id, oldBucket.candidate.delivery.id); + assert.equal(recovered.candidate.delivery.dedupeKey, "scheduled:60:old-bucket"); + assert.equal(recovered.candidate.delivery.attempts, 2); + assert.deepEqual(recovered.explanation, ["recovery:pending-delivery"]); + + const finalizedRecovery = await flowB.finalizeNotification( + recovered.candidate.delivery.id, + recovered.candidate.delivery.version, + { delivered: false, error: "notifications.send failed", at: afterBoundary } + ); + assert.equal(finalizedRecovery.status, "failed"); + assert.equal(finalizedRecovery.attempts, 2); + + const retryAfterFailure = await flowA.reserveNext( + "scheduled", + "scheduled:60:retry-after-failure", + new Date("2026-08-24T12:02:00.000Z") + ); + assert.equal(retryAfterFailure.shouldNotify, true); + assert.ok(retryAfterFailure.candidate); + assert.notEqual( + retryAfterFailure.candidate.delivery.id, + finalizedRecovery.id, + "a failed attempt must remain eligible for a later dedupe bucket" + ); + + let releaseSettingsLock!: () => void; + let settingsLocked!: () => void; + const lockAcquired = new Promise((resolve) => { + settingsLocked = resolve; + }); + const releaseLock = new Promise((resolve) => { + releaseSettingsLock = resolve; + }); + const heldLock = blocker.begin(async (transaction) => { + await transaction` + SELECT * FROM inspiration_flow_settings + WHERE id = 'default' + FOR UPDATE + `; + settingsLocked(); + await releaseLock; + }); + await lockAcquired; + + const abortController = new AbortController(); + const abortedReservation = flowA.reserveNext( + "scheduled", + "scheduled:60:aborted-lock-wait", + new Date("2026-08-24T12:30:00.000Z"), + abortController.signal + ); + const abortedAssertion = assert.rejects( + abortedReservation, + (error) => error instanceof Error && error.name === "AbortError" + ); + abortController.abort(); + releaseSettingsLock(); + await heldLock; + await abortedAssertion; + const abortedRows = await blocker<{ count: number }[]>` + SELECT COUNT(*)::int AS count + FROM inspiration_flow_deliveries + WHERE dedupe_key = 'scheduled:60:aborted-lock-wait' + `; + assert.equal(abortedRows[0]?.count, 0); + } finally { + await Promise.all([ + ...captureStores.map((store) => store.close()), + ...flowStores.map((store) => store.close()), + ]); + if (schemaCreated) { + await admin.unsafe(`DROP SCHEMA ${quotedSchema} CASCADE`); + } + await blocker.end(); + await admin.end(); + } + } +); From 9df75614135c96d7694d96559d17fc9b5c0aabfd Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:38:21 +0800 Subject: [PATCH 07/33] docs(inspiration): finalize task tracking --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d96d66..e1b060c 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 m - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 -- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/08-24-inspiration-plugin/)。 +- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/)。 插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。 From 6e42f207d4151b6436f123385cb7f250d34eca04 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:38:44 +0800 Subject: [PATCH 08/33] chore(task): archive inspiration plugin tasks --- .../2026-08}/08-24-inspiration-capture/check.jsonl | 0 .../{ => archive/2026-08}/08-24-inspiration-capture/design.md | 0 .../2026-08}/08-24-inspiration-capture/implement.jsonl | 0 .../2026-08}/08-24-inspiration-capture/implement.md | 0 .../{ => archive/2026-08}/08-24-inspiration-capture/prd.md | 0 .../{ => archive/2026-08}/08-24-inspiration-capture/task.json | 4 ++-- .../2026-08}/08-24-inspiration-clients/check.jsonl | 0 .../{ => archive/2026-08}/08-24-inspiration-clients/design.md | 0 .../2026-08}/08-24-inspiration-clients/implement.jsonl | 0 .../2026-08}/08-24-inspiration-clients/implement.md | 0 .../{ => archive/2026-08}/08-24-inspiration-clients/prd.md | 0 .../{ => archive/2026-08}/08-24-inspiration-clients/task.json | 4 ++-- .../{ => archive/2026-08}/08-24-inspiration-flow/check.jsonl | 0 .../{ => archive/2026-08}/08-24-inspiration-flow/design.md | 0 .../2026-08}/08-24-inspiration-flow/implement.jsonl | 0 .../{ => archive/2026-08}/08-24-inspiration-flow/implement.md | 0 .../tasks/{ => archive/2026-08}/08-24-inspiration-flow/prd.md | 0 .../{ => archive/2026-08}/08-24-inspiration-flow/task.json | 4 ++-- .../2026-08}/08-24-inspiration-plugin/check.jsonl | 0 .../{ => archive/2026-08}/08-24-inspiration-plugin/design.md | 0 .../2026-08}/08-24-inspiration-plugin/implement.jsonl | 0 .../2026-08}/08-24-inspiration-plugin/implement.md | 0 .../{ => archive/2026-08}/08-24-inspiration-plugin/prd.md | 0 .../08-24-inspiration-plugin/research/plugin-patterns.md | 0 .../{ => archive/2026-08}/08-24-inspiration-plugin/task.json | 4 ++-- 25 files changed, 8 insertions(+), 8 deletions(-) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/task.json (91%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/task.json (91%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/task.json (91%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/research/plugin-patterns.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/task.json (92%) diff --git a/.trellis/tasks/08-24-inspiration-capture/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-capture/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md diff --git a/.trellis/tasks/08-24-inspiration-capture/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md diff --git a/.trellis/tasks/08-24-inspiration-capture/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json similarity index 91% rename from .trellis/tasks/08-24-inspiration-capture/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json index f091446..22614ad 100644 --- a/.trellis/tasks/08-24-inspiration-capture/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json @@ -3,7 +3,7 @@ "name": "inspiration-capture", "title": "Inspiration capture and organization (#33)", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "plugin package metadata, schema, migrations, capture store/routes/tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/08-24-inspiration-clients/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-clients/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md diff --git a/.trellis/tasks/08-24-inspiration-clients/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md diff --git a/.trellis/tasks/08-24-inspiration-clients/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json similarity index 91% rename from .trellis/tasks/08-24-inspiration-clients/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json index e9b8f7b..2826f79 100644 --- a/.trellis/tasks/08-24-inspiration-clients/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json @@ -3,7 +3,7 @@ "name": "inspiration-clients", "title": "Inspiration CLI Web and report clients", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "CLI, Web contribution, report-facing helper, client tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-flow/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md diff --git a/.trellis/tasks/08-24-inspiration-flow/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md diff --git a/.trellis/tasks/08-24-inspiration-flow/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json similarity index 91% rename from .trellis/tasks/08-24-inspiration-flow/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json index 9a0fd30..41c781a 100644 --- a/.trellis/tasks/08-24-inspiration-flow/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json @@ -3,7 +3,7 @@ "name": "inspiration-flow", "title": "Inspiration Flow surfacing (#34)", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "selector, flow store/service/routes/job/notification contract/tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/08-24-inspiration-plugin/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-plugin/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json similarity index 92% rename from .trellis/tasks/08-24-inspiration-plugin/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json index 1b798f4..02f4a09 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json @@ -3,7 +3,7 @@ "name": "inspiration-plugin", "title": "Inspiration bundled plugin", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "plugins/inspiration + bundled registry/build/docs tracking", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, From e2becaa266f9a6f390ea989a65d633e87fbd7a3c Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:41:14 +0800 Subject: [PATCH 09/33] feat(schedule): add bundled schedule plugin --- .trellis/spec/backend/database-guidelines.md | 91 ++++ .trellis/spec/frontend/directory-structure.md | 6 + .../tasks/08-24-schedule-calendar-view/prd.md | 10 +- .../08-24-schedule-calendar-view/task.json | 2 +- .trellis/tasks/08-24-schedule-plugin/prd.md | 12 +- .../tasks/08-24-schedule-plugin/task.json | 2 +- .../tasks/08-24-schedule-reminders/prd.md | 14 +- .../tasks/08-24-schedule-reminders/task.json | 2 +- README.md | 6 +- config.yaml.example | 5 + docs/PLUGIN_API.md | 40 ++ package.json | 3 +- plugins/schedule/README.md | 50 ++ plugins/schedule/config.schema.json | 16 + plugins/schedule/echolog.plugin.json | 25 + plugins/schedule/package.json | 34 ++ plugins/schedule/src/index.ts | 184 +++++++ plugins/schedule/src/reminders.ts | 149 +++++ plugins/schedule/src/routes.ts | 185 +++++++ plugins/schedule/src/schema.ts | 135 +++++ plugins/schedule/src/store.ts | 424 +++++++++++++++ plugins/schedule/src/types.ts | 74 +++ plugins/schedule/src/validation.ts | 386 +++++++++++++ plugins/schedule/tsconfig.json | 8 + plugins/schedule/tsup.config.ts | 10 + plugins/schedule/web/index.js | 469 ++++++++++++++++ plugins/schedule/web/styles.css | 338 ++++++++++++ pnpm-lock.yaml | 25 + src/cli/index.ts | 351 ++++++++++++ src/core/plugins/registry.ts | 5 + tests/schedule-cli.test.ts | 339 ++++++++++++ tests/schedule-web.test.ts | 442 +++++++++++++++ tests/schedule.integration.ts | 392 ++++++++++++++ tests/schedule.test.ts | 512 ++++++++++++++++++ 34 files changed, 4722 insertions(+), 24 deletions(-) create mode 100644 plugins/schedule/README.md create mode 100644 plugins/schedule/config.schema.json create mode 100644 plugins/schedule/echolog.plugin.json create mode 100644 plugins/schedule/package.json create mode 100644 plugins/schedule/src/index.ts create mode 100644 plugins/schedule/src/reminders.ts create mode 100644 plugins/schedule/src/routes.ts create mode 100644 plugins/schedule/src/schema.ts create mode 100644 plugins/schedule/src/store.ts create mode 100644 plugins/schedule/src/types.ts create mode 100644 plugins/schedule/src/validation.ts create mode 100644 plugins/schedule/tsconfig.json create mode 100644 plugins/schedule/tsup.config.ts create mode 100644 plugins/schedule/web/index.js create mode 100644 plugins/schedule/web/styles.css create mode 100644 tests/schedule-cli.test.ts create mode 100644 tests/schedule-web.test.ts create mode 100644 tests/schedule.integration.ts create mode 100644 tests/schedule.test.ts diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index a2fb188..40bc2d5 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -30,6 +30,97 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl - 主键 TEXT,`nanoid(12)`,应用侧生成 - 时间一律 `TIMESTAMPTZ`;「一天」按服务器本地时区切(`localDateStr()`,`getRecordsByDate` 的 dayStart/dayEnd 模式) +## Scenario: Plugin-owned scheduled reminders + +### 1. Scope / Trigger + +- Trigger: a bundled plugin stores scheduled work, polls due rows, calls an + external Host service, and must survive duplicate polls or daemon restart. +- The plugin owns its tables and migration. It must not write Core records or + copy the Core notifier. + +### 2. Signatures + +- Item transitions take `(id, expectedVersion, ...input)` and perform one + `UPDATE ... WHERE id = ? AND version = ? AND status IN (...) RETURNING *`. +- Reminder candidates are exact pairs `(item_id, reminder_at TIMESTAMPTZ)`. +- The notification boundary is + `PluginContext.service("notifications.send")`, accepting + `{title, message}` plus an optional `AbortSignal`. + +### 3. Contracts + +- Store explicit IANA timezone display intent separately from absolute + `TIMESTAMPTZ` instants; HTTP inputs must include `Z` or a numeric offset. +- Derived UI state such as “awaiting confirmation” is calculated from persisted + state + time and is never stored as another status. +- Claim a reminder by inserting a unique ledger key before delivery. A ledger + row in any state (`claimed`, `sent`, or `failed`) makes that exact + item/reminder instant ineligible for another attempt. +- At-most-once means a crash after claim may lose one reminder; restart must not + repeat a possibly delivered notification. A user action that chooses a new + reminder instant creates a new key. +- Delivery never performs an implicit domain transition. Confirm/start, + complete, cancel, and snooze remain explicit versioned mutations. + +### 4. Validation & Error Matrix + +| Condition | Required behavior | +|---|---| +| Missing item | 404 `{error}` | +| Stale version or invalid state | 409 with `currentVersion` and `currentStatus` | +| Bare local datetime / invalid IANA zone | 400 `{error}` | +| Duplicate or restarted poll | Existing ledger excludes the exact instant; no send | +| Host notification failure | Record bounded failure; do not change item state | +| Job abort/timeout | Honor the signal, release Host running state, retain claim | + +### 5. Good/Base/Bad Cases + +- Good: 105 due rows with a batch size of 100 drain as 100 then 5, and a third + poll sees 0; all 105 ledger keys are unique. +- Base: one due item is claimed, notified once, and remains scheduled until an + explicit confirmation. +- Bad: query the oldest 100 due items first, then dedupe in application code. + The same ledgered rows occupy every batch and permanently starve row 101. + +### 6. Tests Required + +- Real PostgreSQL CAS race: two confirmations with one expected version produce + exactly one success and one structured 409. +- Real PostgreSQL poll-limit regression: insert more than one batch, reconstruct + the Store between polls, assert every item is attempted once, then assert zero + remaining candidates. +- Assert `claimed`, `sent`, and `failed` ledger rows are all excluded before + `LIMIT`; a new snooze instant remains eligible. +- Assert failed/ignored delivery does not modify status, confirmed timestamp, or + create a Core record. + +### 7. Wrong vs Correct + +#### Wrong + +```sql +SELECT * FROM schedule_items +WHERE next_reminder_at <= NOW() +ORDER BY next_reminder_at +LIMIT 100; +-- Application code discovers these 100 already have ledger rows. +``` + +#### Correct + +```sql +SELECT i.* FROM schedule_items i +WHERE i.next_reminder_at <= NOW() + AND NOT EXISTS ( + SELECT 1 FROM schedule_reminder_deliveries d + WHERE d.item_id = i.id + AND d.reminder_at = i.next_reminder_at + ) +ORDER BY i.next_reminder_at +LIMIT 100; +``` + ## Common Mistakes - 忘了迁移与 schema.ts 双写,跑起来才发现列不存在 diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md index 6af4a04..059233d 100644 --- a/.trellis/spec/frontend/directory-structure.md +++ b/.trellis/spec/frontend/directory-structure.md @@ -40,3 +40,9 @@ web/ - 翻页手势(wheel/drag)须跳过 `INTERACTIVE` 选择器内的目标 - 重建时加 `.no-anim` 双 rAF 移除,避免翻页动画闪烁 - Chrome 对 `preserve-3d` 翻转背面页的按钮命中不可靠;左页按钮由 `#leftPageHitProxy` 平面透明层接收并按顺序转发给当前 `.leaf.back` 的真实按钮。新增左页按钮时须保持渲染顺序一致,代理层不得保留重复 `id` 或进入键盘焦点序列。 +- 插件可能把同一实体同时渲染到 overview/day 等多个 face,而宿主 `$` + 是全局 `document.getElementById`。交互控件 id 与 action target 必须包含 + face/surface 作用域(例如 `day:`),handler 再安全还原真实 + id;禁止仅用实体 id 生成控件 id,否则不可见页的同名控件会截获当前页输入。 + Web 测试须同时渲染两个 face,为两个控件设置不同值,并断言点击某一 face + 只读取该 face 的值且 API URL 只编码真实实体 id 一次。 diff --git a/.trellis/tasks/08-24-schedule-calendar-view/prd.md b/.trellis/tasks/08-24-schedule-calendar-view/prd.md index c6f8a3a..87e0b99 100644 --- a/.trellis/tasks/08-24-schedule-calendar-view/prd.md +++ b/.trellis/tasks/08-24-schedule-calendar-view/prd.md @@ -21,13 +21,13 @@ user actions, satisfying GitHub #32 without a second calendar model. ## Acceptance Criteria -- [ ] Month, week, and day faces render the same fixture items in their correct +- [x] Month, week, and day faces render the same fixture items in their correct range/day positions and use item-provided timezone semantics. -- [ ] Create/confirm/snooze/done/cancel call only canonical routes and include +- [x] Create/confirm/snooze/done/cancel call only canonical routes and include the item's current `expectedVersion`. -- [ ] Notification ignore has no Web-side write; awaiting is derived. -- [ ] Dynamic text is escaped and stylesheet activation/unmount is tested. -- [ ] Ready gating is covered by the existing host suite plus Schedule-specific +- [x] Notification ignore has no Web-side write; awaiting is derived. +- [x] Dynamic text is escaped and stylesheet activation/unmount is tested. +- [x] Ready gating is covered by the existing host suite plus Schedule-specific contribution tests; no module loads for disabled/degraded. ## Dependency diff --git a/.trellis/tasks/08-24-schedule-calendar-view/task.json b/.trellis/tasks/08-24-schedule-calendar-view/task.json index 6880820..23cdf8b 100644 --- a/.trellis/tasks/08-24-schedule-calendar-view/task.json +++ b/.trellis/tasks/08-24-schedule-calendar-view/task.json @@ -3,7 +3,7 @@ "name": "schedule-calendar-view", "title": "Schedule calendar views (#32)", "description": "", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": "frontend", "package": null, diff --git a/.trellis/tasks/08-24-schedule-plugin/prd.md b/.trellis/tasks/08-24-schedule-plugin/prd.md index b5e8f55..074d891 100644 --- a/.trellis/tasks/08-24-schedule-plugin/prd.md +++ b/.trellis/tasks/08-24-schedule-plugin/prd.md @@ -42,15 +42,15 @@ cross-child contract and final integration for GitHub Issues #31 and #32. ## Acceptance Criteria -- [ ] Both child acceptance suites pass and use one `plugins/schedule` package. -- [ ] README, GitHub #31/#32, and this task tree point to the same branch, +- [x] Both child acceptance suites pass and use one `plugins/schedule` package. +- [x] README, GitHub #31/#32, and this task tree point to the same branch, package, semantics, and verification state. -- [ ] Web loads Schedule only while the bundled plugin is enabled and `ready`. -- [ ] Missing `notifications.send` degrades only Schedule; it does not prevent +- [x] Web loads Schedule only while the bundled plugin is enabled and `ready`. +- [x] Missing `notifications.send` degrades only Schedule; it does not prevent Core or another plugin from starting. -- [ ] `pnpm test`, `pnpm typecheck`, and `pnpm build` pass from the repository +- [x] `pnpm test`, `pnpm typecheck`, and `pnpm build` pass from the repository root after integration. -- [ ] An independent check agent reviews the integrated diff after all three +- [x] An independent check agent reviews the integrated diff after all three implementation agents finish, and verified findings are resolved. - [ ] Changes are committed on `codex/schedule-plugin` without merging any sibling branch. diff --git a/.trellis/tasks/08-24-schedule-plugin/task.json b/.trellis/tasks/08-24-schedule-plugin/task.json index 2b65beb..18b61ef 100644 --- a/.trellis/tasks/08-24-schedule-plugin/task.json +++ b/.trellis/tasks/08-24-schedule-plugin/task.json @@ -3,7 +3,7 @@ "name": "schedule-plugin", "title": "Schedule bundled plugin", "description": "", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": "cross-layer", "package": null, diff --git a/.trellis/tasks/08-24-schedule-reminders/prd.md b/.trellis/tasks/08-24-schedule-reminders/prd.md index d4c3f51..2941823 100644 --- a/.trellis/tasks/08-24-schedule-reminders/prd.md +++ b/.trellis/tasks/08-24-schedule-reminders/prd.md @@ -20,18 +20,18 @@ delivery ledger/job, canonical HTTP API, and `el schedule` client for GitHub #31 ## Acceptance Criteria -- [ ] Migrations create constrained/indexed `schedule_items` and a reminder +- [x] Migrations create constrained/indexed `schedule_items` and a reminder ledger with a unique dedupe key; every instant is `TIMESTAMPTZ`. -- [ ] CRUD/list/range and all state routes validate input and preserve the +- [x] CRUD/list/range and all state routes validate input and preserve the parent JSON contract including derived `awaitingConfirmation`. -- [ ] Two concurrent confirms with the same expected version yield one active +- [x] Two concurrent confirms with the same expected version yield one active item and one 409; `confirmedStartAt` reflects the winner's confirmation. -- [ ] Due polling, repeated polling, daemon/store restart, snooze, abort, and +- [x] Due polling, repeated polling, daemon/store restart, snooze, abort, and notification failure have deterministic tests. -- [ ] Arrival/failed/ignored reminders do not start, complete, cancel, or create +- [x] Arrival/failed/ignored reminders do not start, complete, cancel, or create any Core record. -- [ ] Disabled and missing-service/degraded cases remain isolated by Host tests. -- [ ] `el schedule` list/show/add/edit/confirm/snooze/done/cancel meets the CLI +- [x] Disabled and missing-service/degraded cases remain isolated by Host tests. +- [x] `el schedule` list/show/add/edit/confirm/snooze/done/cancel meets the CLI agent contract in human and JSON modes. ## Dependency diff --git a/.trellis/tasks/08-24-schedule-reminders/task.json b/.trellis/tasks/08-24-schedule-reminders/task.json index cdc6186..611b39f 100644 --- a/.trellis/tasks/08-24-schedule-reminders/task.json +++ b/.trellis/tasks/08-24-schedule-reminders/task.json @@ -3,7 +3,7 @@ "name": "schedule-reminders", "title": "Schedule data and reminders (#31)", "description": "", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": "backend-cli", "package": null, diff --git a/README.md b/README.md index d3573f5..a28ddcf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - **父子任务**:一个大任务可挂多层小任务;服务端防止自指/成环,CLI 与 Web 可创建、查询并查看直接子任务进度 - **笔记**:给任意记录追加 `note | blocker | next` - **补录与编辑**:`el add --at --for`、`el edit` -- **内置插件**:screen-time 采样和追溯分类前台应用;tmux-status 通过外部 CLI 提供结构化 pane/资源观测,并以 v3 合约持久化已验证的 Agent conversation↔pane 恢复映射 +- **内置插件**:schedule 提供显式确认的日程提醒与月/周/日视图;screen-time 采样和追溯分类前台应用;tmux-status 通过外部 CLI 提供结构化 pane/资源观测,并以 v3 合约持久化已验证的 Agent conversation↔pane 恢复映射 - **汇总与日报**:今日/指定日汇总、日报 Markdown 生成、可同步到指定目录 - **提醒**(可选):任务超时、空闲提醒、macOS 通知 + ntfy 推送到手机 - **四个入口,一套 REST API**:免构建的 Web 控制台、`el` CLI、本地 stdio MCP、HTTP API(`docs/API.md`) @@ -90,6 +90,7 @@ el report # 输出日报 Markdown ```bash el status --json # 今日概览 + 活跃任务 el log --json -n 50 # 历史记录 +el schedule list --json # 日程与明确状态;提醒不会自动开始 el screen --json # 今日屏幕使用(macOS) el plugins list --json # 内置插件清单与状态 el tmux status --json # tmux-status 原始快照(插件默认禁用) @@ -109,6 +110,7 @@ el tmux status --json # tmux-status 原始快照(插件默认禁用) |---|---| | `server` | 端口(默认 19827)、`apiKey`(本机豁免,非本机必带)、`serveWeb`(false = 纯 API 服务)、`corsOrigins`(跨源白名单,默认不允许跨源) | | `database` | PostgreSQL 连接(与 docker-compose 默认值对应) | +| `plugins.schedule` | 日程提醒轮询频率(默认启用);到点只提醒,必须显式确认开始 | | `plugins.screen-time` | 屏幕采样开关、频率与空闲阈值(默认启用) | | `plugins.tmux-status` | 外部 executable、超时、采样频率、异常阈值,以及 v3 Agent conversation↔pane 恢复映射(默认禁用) | | `sync` | 日报 Markdown 同步目标目录 | @@ -185,7 +187,7 @@ EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 m - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 -- **schedule(开发中)**:以同一套日程数据提供显式确认开始、延后提醒、完成/取消,以及月/周/日视图;到点只提醒,绝不自动启动或创建 Core record。实现追踪见 [Issue #31](https://github.com/CubePlus1/echolog/issues/31)、[Issue #32](https://github.com/CubePlus1/echolog/issues/32) 与 [Trellis 父任务](.trellis/tasks/08-24-schedule-plugin/)。 +- **schedule**:以同一套日程数据提供显式确认开始、延后提醒、完成/取消,以及月/周/日视图;到点只提醒,绝不自动启动或创建 Core record。它只通过 Host 的 `notifications.send` 命名服务投递,能力缺失时仅本插件 degraded。实现追踪见 [Issue #31](https://github.com/CubePlus1/echolog/issues/31)、[Issue #32](https://github.com/CubePlus1/echolog/issues/32) 与 [Trellis 父任务](.trellis/tasks/08-24-schedule-plugin/)。 插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。 diff --git a/config.yaml.example b/config.yaml.example index 22fb431..5e43433 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -26,6 +26,11 @@ sync: auto: false plugins: + schedule: + enabled: true + config: + # 到点只提醒,不会自动开始;轮询由 Host 保证不重入。 + reminder_poll_seconds: 30 screen-time: enabled: true config: diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 31d438a..c9758cd 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -191,6 +191,46 @@ then delegates data loading, face descriptions, rendering and actions. A module failure removes only that contribution. Disabled plugins do not add navigation or pages. +## Bundled Schedule plugin + +`schedule` owns its manifest, configuration, migrations, `schedule_items`, +reminder delivery ledger, routes, job, CLI, and Web contribution. Its canonical +routes use `/api/plugins/schedule/*`; `el schedule` and the month/week/day +views are HTTP clients of those routes. + +Canonical routes: + +- `GET|POST /api/plugins/schedule/items` +- `GET|PATCH /api/plugins/schedule/items/:id` +- `POST /api/plugins/schedule/items/:id/confirm-start` +- `POST /api/plugins/schedule/items/:id/snooze` +- `POST /api/plugins/schedule/items/:id/complete` +- `POST /api/plugins/schedule/items/:id/cancel` +- `GET /api/plugins/schedule/reminders` + +`el schedule` exposes `list`, `show`, `add`, `edit`, `confirm`, +`snooze`, `done`, and `cancel`; `--json` preserves the API response or +structured error body. + +The plugin requests the exact named service `notifications.send` and declares +`notifications:send`. Its local consumer contract sends only +`{ title, message }` plus an optional `AbortSignal`, and receives independent +`mac` and `ntfy` results with status `sent`, `disabled`, or `failed`. +Notification configuration and credentials remain Core-owned. + +Reaching `scheduledStartAt` only attempts a notification. It never changes +state or creates/starts a Core record. Only explicit `confirm-start` changes +`scheduled` to `active`, recording the confirmation time as +`confirmedStartAt`. Ignoring a reminder changes nothing; snooze changes only +`nextReminderAt`; completion and cancellation are explicit. + +Persisted states are `scheduled | active | done | cancelled`. +`awaitingConfirmation` is derived from a scheduled item whose planned start is +not later than now. Month, week, and day views project the same +`schedule_items` rows; there is no separate calendar event store. All state +mutations require `expectedVersion`, and each reminder instant is claimed by a +unique ledger dedupe key before delivery. + ## Compatibility policy API v1 changes are additive. A breaking SDK, lifecycle or manifest change diff --git a/package.json b/package.json index 0658904..4b1e2e8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "dev": "tsx src/server/app.ts", - "build": "pnpm --filter @echolog/plugin-sdk build && pnpm --filter @echolog/plugin-screen-time build && pnpm --filter @echolog/plugin-tmux-status build && tsup", + "build": "pnpm --filter @echolog/plugin-sdk build && pnpm --filter @echolog/plugin-screen-time build && pnpm --filter @echolog/plugin-tmux-status build && pnpm --filter @echolog/plugin-schedule build && tsup", "build:macos-capture": "bash scripts/build-macos-capture.sh", "build:macos-release": "pnpm build && pnpm build:macos-capture", "package:macos": "bash scripts/package-release.sh --version 0.2.0 --adhoc", @@ -22,6 +22,7 @@ }, "dependencies": { "@echolog/plugin-screen-time": "workspace:*", + "@echolog/plugin-schedule": "workspace:*", "@echolog/plugin-sdk": "workspace:*", "@echolog/plugin-tmux-status": "workspace:*", "@fastify/cors": "^11.0.0", diff --git a/plugins/schedule/README.md b/plugins/schedule/README.md new file mode 100644 index 0000000..e0fecff --- /dev/null +++ b/plugins/schedule/README.md @@ -0,0 +1,50 @@ +# Schedule bundled plugin + +Schedule owns planned items, explicit execution state, reminder delivery +deduplication, the `el schedule` HTTP client, and the Web month/week/day views. +It is independent of Inspiration and Core records. + +```yaml +plugins: + schedule: + enabled: true + config: + reminder_poll_seconds: 30 +``` + +Reaching `scheduledStartAt` only asks the Host to send a reminder. It never +starts work or creates a Core record. The persisted states are +`scheduled | active | done | cancelled`; `awaitingConfirmation` is derived +for a scheduled item whose planned start has arrived. + +- `confirm-start` is the only transition to `active` and records the actual + confirmation time in `confirmedStartAt`. +- Ignoring a notification changes nothing. +- Snooze changes only `nextReminderAt` plus normal version/update bookkeeping. +- Complete and cancel are explicit. +- Every mutation requires the current `expectedVersion`. + +The reminder job claims a unique item/reminder-instant ledger key before calling +`PluginContext.service("notifications.send")`. The manifest declares +`notifications:send`; Schedule locally consumes only `{title,message}`, an +optional `AbortSignal`, and per-channel `sent | disabled | failed` results. +It does not import the Core notifier or access notification configuration. +Missing service capability degrades only this plugin. + +Canonical routes: + +- `GET|POST /api/plugins/schedule/items` +- `GET|PATCH /api/plugins/schedule/items/:id` +- `POST /api/plugins/schedule/items/:id/confirm-start` +- `POST /api/plugins/schedule/items/:id/snooze` +- `POST /api/plugins/schedule/items/:id/complete` +- `POST /api/plugins/schedule/items/:id/cancel` +- `GET /api/plugins/schedule/reminders` + +`el schedule --help` documents list/show/add/edit/confirm/snooze/done/cancel, +explicit-offset ISO timestamps, IANA timezones, JSON output, and optimistic +version handling. + +The MVP deliberately excludes recurrence, external calendar sync, AI +scheduling, notification action callbacks, Inspiration conversion, and Core +record linkage. Web and CLI provide explicit confirmation and state actions. diff --git a/plugins/schedule/config.schema.json b/plugins/schedule/config.schema.json new file mode 100644 index 0000000..d250508 --- /dev/null +++ b/plugins/schedule/config.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://echolog.local/plugins/schedule/config.schema.json", + "title": "Schedule plugin configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "reminder_poll_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600, + "default": 30, + "description": "How often the Host asks Schedule to claim due reminders" + } + } +} diff --git a/plugins/schedule/echolog.plugin.json b/plugins/schedule/echolog.plugin.json new file mode 100644 index 0000000..2126e83 --- /dev/null +++ b/plugins/schedule/echolog.plugin.json @@ -0,0 +1,25 @@ +{ + "manifestVersion": 1, + "id": "schedule", + "version": "1.0.0", + "apiVersion": "1", + "displayName": "Schedule", + "description": "Explicitly confirmed schedules, reminders, and calendar views", + "entries": { + "server": "./dist/index.js", + "web": "/plugins/schedule/index.js" + }, + "capabilities": [ + "schedule-items", + "schedule-reminders", + "schedule-calendar" + ], + "permissions": [ + "database:plugin", + "notifications:send" + ], + "requires": { + "coreApi": "^1.0.0" + }, + "configSchema": "./config.schema.json" +} diff --git a/plugins/schedule/package.json b/plugins/schedule/package.json new file mode 100644 index 0000000..3ba8765 --- /dev/null +++ b/plugins/schedule/package.json @@ -0,0 +1,34 @@ +{ + "name": "@echolog/plugin-schedule", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "web", + "echolog.plugin.json", + "config.schema.json" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@echolog/plugin-sdk": "workspace:*", + "drizzle-orm": "^0.44.0", + "nanoid": "^5.1.5", + "postgres": "^3.4.7" + }, + "devDependencies": { + "tsup": "^8.5.0", + "typescript": "^5.8.3" + } +} diff --git a/plugins/schedule/src/index.ts b/plugins/schedule/src/index.ts new file mode 100644 index 0000000..415a46c --- /dev/null +++ b/plugins/schedule/src/index.ts @@ -0,0 +1,184 @@ +import type { + PluginDefinition, + PluginManifest, +} from "@echolog/plugin-sdk"; +import manifestJson from "../echolog.plugin.json"; +import { pollDueReminders } from "./reminders.js"; +import { createScheduleRoutes } from "./routes.js"; +import { ScheduleStore } from "./store.js"; +import type { NotificationSend } from "./types.js"; + +const manifest = manifestJson as PluginManifest; +let currentStore: ScheduleStore | null = null; +let notificationSend: NotificationSend | null = null; + +export const SCHEDULE_REMINDER_JOB_TIMEOUT_MS = 25_000; + +function requireStore(): ScheduleStore { + if (!currentStore) throw new Error("schedule store is not initialized"); + return currentStore; +} + +function requireNotificationSend(): NotificationSend { + if (!notificationSend) { + throw new Error("schedule notifications service is not initialized"); + } + return notificationSend; +} + +function reminderPollSeconds(config: Readonly>): number { + const value = config.reminder_poll_seconds; + return typeof value === "number" && Number.isInteger(value) ? value : 30; +} + +export const schedulePlugin: PluginDefinition = { + manifest, + routes: createScheduleRoutes(requireStore), + defaultEnabled: true, + defaultConfig: { + reminder_poll_seconds: 30, + }, + normalizeConfig(config) { + return { + reminder_poll_seconds: config.reminder_poll_seconds ?? 30, + }; + }, + validateConfig(config) { + const value = config.reminder_poll_seconds; + return typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value <= 3_600 + ? [] + : ["reminder_poll_seconds must be an integer from 1 to 3600"]; + }, + migrations: [{ + name: "001_schedule_items_and_reminder_deliveries", + sql: ` + CREATE TABLE IF NOT EXISTS schedule_items ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + scheduled_start_at TIMESTAMPTZ NOT NULL, + scheduled_end_at TIMESTAMPTZ, + timezone TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'scheduled', + next_reminder_at TIMESTAMPTZ, + confirmed_start_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT schedule_items_title_check + CHECK (char_length(btrim(title)) BETWEEN 1 AND 200), + CONSTRAINT schedule_items_description_check + CHECK (description IS NULL OR char_length(description) <= 5000), + CONSTRAINT schedule_items_timezone_check + CHECK (char_length(btrim(timezone)) BETWEEN 1 AND 100), + CONSTRAINT schedule_items_status_check + CHECK (status IN ('scheduled', 'active', 'done', 'cancelled')), + CONSTRAINT schedule_items_version_check CHECK (version >= 1), + CONSTRAINT schedule_items_priority_check + CHECK (priority BETWEEN -1000 AND 1000), + CONSTRAINT schedule_items_interval_check + CHECK (scheduled_end_at IS NULL OR scheduled_end_at > scheduled_start_at), + CONSTRAINT schedule_items_state_timestamps_check CHECK ( + (status = 'scheduled' + AND confirmed_start_at IS NULL + AND completed_at IS NULL + AND cancelled_at IS NULL) + OR (status = 'active' + AND confirmed_start_at IS NOT NULL + AND completed_at IS NULL + AND cancelled_at IS NULL + AND next_reminder_at IS NULL) + OR (status = 'done' + AND completed_at IS NOT NULL + AND cancelled_at IS NULL + AND next_reminder_at IS NULL) + OR (status = 'cancelled' + AND completed_at IS NULL + AND cancelled_at IS NOT NULL + AND next_reminder_at IS NULL) + ) + ); + CREATE INDEX IF NOT EXISTS idx_schedule_items_status_reminder + ON schedule_items(status, next_reminder_at); + CREATE INDEX IF NOT EXISTS idx_schedule_items_calendar_range + ON schedule_items(scheduled_start_at, scheduled_end_at); + + CREATE TABLE IF NOT EXISTS schedule_reminder_deliveries ( + id TEXT PRIMARY KEY, + dedupe_key TEXT NOT NULL, + item_id TEXT NOT NULL REFERENCES schedule_items(id) ON DELETE CASCADE, + reminder_at TIMESTAMPTZ NOT NULL, + attempted_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ, + status TEXT NOT NULL, + channel_results JSONB, + failure TEXT, + CONSTRAINT schedule_reminder_deliveries_status_check + CHECK (status IN ('claimed', 'sent', 'failed')), + CONSTRAINT schedule_reminder_deliveries_failure_check + CHECK (failure IS NULL OR char_length(failure) <= 1000), + CONSTRAINT schedule_reminder_deliveries_terminal_check CHECK ( + (status = 'claimed' AND completed_at IS NULL AND channel_results IS NULL) + OR (status = 'sent' AND completed_at IS NOT NULL AND channel_results IS NOT NULL) + OR (status = 'failed' AND completed_at IS NOT NULL) + ) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_schedule_reminder_deliveries_dedupe_key + ON schedule_reminder_deliveries(dedupe_key); + CREATE INDEX IF NOT EXISTS idx_schedule_reminder_deliveries_attempted_at + ON schedule_reminder_deliveries(attempted_at); + `, + }], + register(context) { + currentStore = new ScheduleStore(context.service("database.url")); + notificationSend = context.service("notifications.send"); + context.registerJob({ + id: "reminder-poll", + intervalMs: reminderPollSeconds(context.config) * 1_000, + timeoutMs: SCHEDULE_REMINDER_JOB_TIMEOUT_MS, + async run(signal) { + await pollDueReminders( + requireStore(), + requireNotificationSend(), + signal + ); + }, + }); + }, + start(context) { + context.logger.info( + { reminderPollSeconds: reminderPollSeconds(context.config) }, + "Schedule plugin started" + ); + }, + async stop() { + await currentStore?.close(); + currentStore = null; + notificationSend = null; + }, +}; + +export default schedulePlugin; + +export { pollDueReminders } from "./reminders.js"; +export { createScheduleRoutes } from "./routes.js"; +export { + ScheduleConflictError, + ScheduleNotFoundError, + ScheduleStore, + reminderDedupeKey, + scheduleItemFromRow, +} from "./store.js"; +export type { + NotificationSend, + NotificationSendResult, + ReminderDelivery, + ScheduleItem, + ScheduleStatus, +} from "./types.js"; diff --git a/plugins/schedule/src/reminders.ts b/plugins/schedule/src/reminders.ts new file mode 100644 index 0000000..3e1bdae --- /dev/null +++ b/plugins/schedule/src/reminders.ts @@ -0,0 +1,149 @@ +import type { DueReminder } from "./store.js"; +import type { + NotificationChannelResult, + NotificationSend, + NotificationSendResult, + ReminderDelivery, +} from "./types.js"; + +export interface ReminderStore { + dueReminders(now?: Date, limit?: number): Promise; + claimReminder( + itemId: string, + reminderAt: Date, + attemptedAt?: Date + ): Promise; + finishReminder( + id: string, + input: { + status: "sent" | "failed"; + channelResults: NotificationSendResult["channels"] | null; + failure: string | null; + }, + completedAt?: Date + ): Promise; +} + +export interface ReminderPollResult { + due: number; + claimed: number; + sent: number; + failed: number; + deduplicated: number; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function validChannelResult(value: unknown): value is NotificationChannelResult { + if (!value || typeof value !== "object") return false; + const result = value as Record; + return result.status === "sent" || + result.status === "disabled" || + (result.status === "failed" && typeof result.error === "string"); +} + +function validateNotificationResult(value: unknown): NotificationSendResult { + if (!value || typeof value !== "object") { + throw new Error("notifications.send returned an invalid result"); + } + const channels = (value as { channels?: unknown }).channels; + if (!channels || typeof channels !== "object") { + throw new Error("notifications.send returned an invalid channels result"); + } + const record = channels as Record; + if (!validChannelResult(record.mac) || !validChannelResult(record.ntfy)) { + throw new Error("notifications.send must return mac and ntfy channel results"); + } + return { + channels: { mac: record.mac, ntfy: record.ntfy }, + }; +} + +function resultOutcome(result: NotificationSendResult): { + status: "sent" | "failed"; + failure: string | null; +} { + const entries = Object.entries(result.channels) as Array< + ["mac" | "ntfy", NotificationChannelResult] + >; + const sent = entries.some(([, channel]) => channel.status === "sent"); + const details = entries.flatMap(([name, channel]) => { + if (channel.status === "failed") return [`${name}: ${channel.error}`]; + if (channel.status === "disabled") return [`${name}: disabled`]; + return []; + }); + return { + status: sent ? "sent" : "failed", + failure: details.length ? details.join("; ") : null, + }; +} + +function notificationMessage(reminder: DueReminder): string { + const item = reminder.item; + const description = item.description?.trim(); + return [ + `Scheduled for ${item.scheduledStartAt} (${item.timezone}).`, + description || null, + "Open EchoLog or use el schedule confirm to start explicitly.", + ].filter(Boolean).join("\n"); +} + +export async function pollDueReminders( + store: ReminderStore, + send: NotificationSend, + signal: AbortSignal, + options: { now?: Date; limit?: number } = {} +): Promise { + signal.throwIfAborted(); + const now = options.now ?? new Date(); + const due = await store.dueReminders(now, options.limit ?? 100); + const summary: ReminderPollResult = { + due: due.length, + claimed: 0, + sent: 0, + failed: 0, + deduplicated: 0, + }; + + for (const reminder of due) { + signal.throwIfAborted(); + const claimed = await store.claimReminder( + reminder.item.id, + reminder.reminderAt, + now + ); + if (!claimed) { + summary.deduplicated++; + continue; + } + summary.claimed++; + + let result: NotificationSendResult; + try { + signal.throwIfAborted(); + result = validateNotificationResult(await send({ + title: `Schedule reminder: ${reminder.item.title}`, + message: notificationMessage(reminder), + }, signal)); + } catch (error) { + await store.finishReminder(claimed.id, { + status: "failed", + channelResults: null, + failure: errorMessage(error), + }, new Date()); + summary.failed++; + if (signal.aborted) throw error; + continue; + } + const outcome = resultOutcome(result); + await store.finishReminder(claimed.id, { + status: outcome.status, + channelResults: result.channels, + failure: outcome.failure, + }, new Date()); + summary[outcome.status]++; + } + return summary; +} diff --git a/plugins/schedule/src/routes.ts b/plugins/schedule/src/routes.ts new file mode 100644 index 0000000..6471116 --- /dev/null +++ b/plugins/schedule/src/routes.ts @@ -0,0 +1,185 @@ +import type { + PluginHttpRequest, + PluginHttpResponse, + PluginRoute, +} from "@echolog/plugin-sdk"; +import { + ScheduleConflictError, + ScheduleNotFoundError, + type ScheduleStore, +} from "./store.js"; +import { + validateCreateScheduleItem, + validateEditScheduleItem, + validateExpectedVersionBody, + validateItemId, + validateListQuery, + validateReminderQuery, + validateScheduleInterval, + validateSnoozeBody, +} from "./validation.js"; + +type StoreProvider = () => ScheduleStore; + +function response(statusCode: number, body: unknown): PluginHttpResponse { + return { statusCode, body }; +} + +function scheduleError(error: unknown): PluginHttpResponse { + if (error instanceof ScheduleNotFoundError) { + return response(404, { error: error.message }); + } + if (error instanceof ScheduleConflictError) { + return response(409, { + error: error.message, + currentVersion: error.metadata.currentVersion, + currentStatus: error.metadata.currentStatus, + }); + } + throw error; +} + +function invalidItemId(request: PluginHttpRequest): PluginHttpResponse | null { + const error = validateItemId(request.params.id); + return error ? response(400, { error }) : null; +} + +export function createScheduleRoutes(store: StoreProvider): PluginRoute[] { + const prefix = "/api/plugins/schedule"; + return [ + { + method: "GET", + path: `${prefix}/items`, + async handler(request) { + const validated = validateListQuery(request.query); + if (!validated.ok) return response(400, { error: validated.error }); + return store().list(validated.value); + }, + }, + { + method: "POST", + path: `${prefix}/items`, + async handler(request) { + const validated = validateCreateScheduleItem(request.body); + if (!validated.ok) return response(400, { error: validated.error }); + return response(201, await store().create(validated.value)); + }, + }, + { + method: "GET", + path: `${prefix}/items/:id`, + async handler(request) { + const invalid = invalidItemId(request); + if (invalid) return invalid; + const item = await store().get(request.params.id); + return item ?? response(404, { + error: `Schedule item ${request.params.id} not found`, + }); + }, + }, + { + method: "PATCH", + path: `${prefix}/items/:id`, + async handler(request) { + const invalid = invalidItemId(request); + if (invalid) return invalid; + const validated = validateEditScheduleItem(request.body); + if (!validated.ok) return response(400, { error: validated.error }); + try { + const current = await store().get(request.params.id); + if (!current) throw new ScheduleNotFoundError(request.params.id); + if ( + current.version === validated.value.expectedVersion && + current.status === "scheduled" + ) { + const start = validated.value.changes.scheduledStartAt ?? + new Date(current.scheduledStartAt); + const end = Object.hasOwn(validated.value.changes, "scheduledEndAt") + ? validated.value.changes.scheduledEndAt ?? null + : current.scheduledEndAt + ? new Date(current.scheduledEndAt) + : null; + const intervalError = validateScheduleInterval(start, end); + if (intervalError) return response(400, { error: intervalError }); + } + return await store().edit( + request.params.id, + validated.value.expectedVersion, + validated.value.changes + ); + } catch (error) { + return scheduleError(error); + } + }, + }, + { + method: "POST", + path: `${prefix}/items/:id/confirm-start`, + async handler(request) { + const invalid = invalidItemId(request); + if (invalid) return invalid; + const validated = validateExpectedVersionBody(request.body); + if (!validated.ok) return response(400, { error: validated.error }); + try { + return await store().confirmStart( + request.params.id, + validated.value.expectedVersion + ); + } catch (error) { + return scheduleError(error); + } + }, + }, + { + method: "POST", + path: `${prefix}/items/:id/snooze`, + async handler(request) { + const invalid = invalidItemId(request); + if (invalid) return invalid; + const validated = validateSnoozeBody(request.body); + if (!validated.ok) return response(400, { error: validated.error }); + try { + return await store().snooze( + request.params.id, + validated.value.expectedVersion, + validated.value.nextReminderAt + ); + } catch (error) { + return scheduleError(error); + } + }, + }, + ...(["complete", "cancel"] as const).map((action): PluginRoute => ({ + method: "POST", + path: `${prefix}/items/:id/${action}`, + async handler(request) { + const invalid = invalidItemId(request); + if (invalid) return invalid; + const validated = validateExpectedVersionBody(request.body); + if (!validated.ok) return response(400, { error: validated.error }); + try { + return action === "complete" + ? await store().complete( + request.params.id, + validated.value.expectedVersion + ) + : await store().cancel( + request.params.id, + validated.value.expectedVersion + ); + } catch (error) { + return scheduleError(error); + } + }, + })), + { + method: "GET", + path: `${prefix}/reminders`, + async handler(request) { + const validated = validateReminderQuery(request.query); + if (!validated.ok) return response(400, { error: validated.error }); + return store().listReminders(validated.value); + }, + }, + ]; +} diff --git a/plugins/schedule/src/schema.ts b/plugins/schedule/src/schema.ts new file mode 100644 index 0000000..7003774 --- /dev/null +++ b/plugins/schedule/src/schema.ts @@ -0,0 +1,135 @@ +import { sql } from "drizzle-orm"; +import { + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { + NotificationSendResult, + ReminderDeliveryStatus, + ScheduleStatus, +} from "./types.js"; + +export const scheduleItems = pgTable( + "schedule_items", + { + id: text("id").primaryKey(), + title: text("title").notNull(), + description: text("description"), + scheduledStartAt: timestamp("scheduled_start_at", { withTimezone: true }) + .notNull(), + scheduledEndAt: timestamp("scheduled_end_at", { withTimezone: true }), + timezone: text("timezone").notNull(), + priority: integer("priority").notNull().default(0), + status: text("status").$type().notNull().default("scheduled"), + nextReminderAt: timestamp("next_reminder_at", { withTimezone: true }), + confirmedStartAt: timestamp("confirmed_start_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + cancelledAt: timestamp("cancelled_at", { withTimezone: true }), + version: integer("version").notNull().default(1), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check( + "schedule_items_title_check", + sql`char_length(btrim(${table.title})) BETWEEN 1 AND 200` + ), + check( + "schedule_items_description_check", + sql`${table.description} IS NULL OR char_length(${table.description}) <= 5000` + ), + check( + "schedule_items_timezone_check", + sql`char_length(btrim(${table.timezone})) BETWEEN 1 AND 100` + ), + check( + "schedule_items_status_check", + sql`${table.status} IN ('scheduled', 'active', 'done', 'cancelled')` + ), + check("schedule_items_version_check", sql`${table.version} >= 1`), + check( + "schedule_items_priority_check", + sql`${table.priority} BETWEEN -1000 AND 1000` + ), + check( + "schedule_items_interval_check", + sql`${table.scheduledEndAt} IS NULL OR ${table.scheduledEndAt} > ${table.scheduledStartAt}` + ), + check( + "schedule_items_state_timestamps_check", + sql`(${table.status} = 'scheduled' + AND ${table.confirmedStartAt} IS NULL + AND ${table.completedAt} IS NULL + AND ${table.cancelledAt} IS NULL) + OR (${table.status} = 'active' + AND ${table.confirmedStartAt} IS NOT NULL + AND ${table.completedAt} IS NULL + AND ${table.cancelledAt} IS NULL + AND ${table.nextReminderAt} IS NULL) + OR (${table.status} = 'done' + AND ${table.completedAt} IS NOT NULL + AND ${table.cancelledAt} IS NULL + AND ${table.nextReminderAt} IS NULL) + OR (${table.status} = 'cancelled' + AND ${table.completedAt} IS NULL + AND ${table.cancelledAt} IS NOT NULL + AND ${table.nextReminderAt} IS NULL)` + ), + index("idx_schedule_items_status_reminder").on( + table.status, + table.nextReminderAt + ), + index("idx_schedule_items_calendar_range").on( + table.scheduledStartAt, + table.scheduledEndAt + ), + ] +); + +export const scheduleReminderDeliveries = pgTable( + "schedule_reminder_deliveries", + { + id: text("id").primaryKey(), + dedupeKey: text("dedupe_key").notNull(), + itemId: text("item_id") + .notNull() + .references(() => scheduleItems.id, { onDelete: "cascade" }), + reminderAt: timestamp("reminder_at", { withTimezone: true }).notNull(), + attemptedAt: timestamp("attempted_at", { withTimezone: true }).notNull(), + completedAt: timestamp("completed_at", { withTimezone: true }), + status: text("status").$type().notNull(), + channelResults: jsonb("channel_results") + .$type(), + failure: text("failure"), + }, + (table) => [ + uniqueIndex("idx_schedule_reminder_deliveries_dedupe_key").on( + table.dedupeKey + ), + index("idx_schedule_reminder_deliveries_attempted_at").on(table.attemptedAt), + check( + "schedule_reminder_deliveries_status_check", + sql`${table.status} IN ('claimed', 'sent', 'failed')` + ), + check( + "schedule_reminder_deliveries_failure_check", + sql`${table.failure} IS NULL OR char_length(${table.failure}) <= 1000` + ), + check( + "schedule_reminder_deliveries_terminal_check", + sql`(${table.status} = 'claimed' AND ${table.completedAt} IS NULL AND ${table.channelResults} IS NULL) + OR (${table.status} = 'sent' AND ${table.completedAt} IS NOT NULL AND ${table.channelResults} IS NOT NULL) + OR (${table.status} = 'failed' AND ${table.completedAt} IS NOT NULL)` + ), + ] +); + +export type ScheduleItemRow = typeof scheduleItems.$inferSelect; +export type ScheduleReminderDeliveryRow = + typeof scheduleReminderDeliveries.$inferSelect; diff --git a/plugins/schedule/src/store.ts b/plugins/schedule/src/store.ts new file mode 100644 index 0000000..7287d6f --- /dev/null +++ b/plugins/schedule/src/store.ts @@ -0,0 +1,424 @@ +import { + and, + asc, + desc, + eq, + gte, + gt, + inArray, + isNotNull, + isNull, + lte, + lt, + notExists, + or, + sql, + type SQL, +} from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { nanoid } from "nanoid"; +import postgres from "postgres"; +import { + scheduleItems, + scheduleReminderDeliveries, + type ScheduleItemRow, + type ScheduleReminderDeliveryRow, +} from "./schema.js"; +import type { + CreateScheduleItemInput, + EditScheduleItemInput, + NotificationSendResult, + ReminderDelivery, + ScheduleConflictMetadata, + ScheduleItem, + ScheduleStatus, +} from "./types.js"; + +export class ScheduleNotFoundError extends Error { + constructor(public readonly itemId: string) { + super(`Schedule item ${itemId} not found`); + this.name = "ScheduleNotFoundError"; + } +} + +export class ScheduleConflictError extends Error { + constructor( + public readonly itemId: string, + public readonly expectedVersion: number, + public readonly metadata: ScheduleConflictMetadata + ) { + super(`Schedule item ${itemId} has changed or cannot perform this action`); + this.name = "ScheduleConflictError"; + } +} + +export interface ScheduleListFilter { + from?: Date; + to?: Date; + statuses?: ScheduleStatus[]; +} + +export interface DueReminder { + item: ScheduleItem; + reminderAt: Date; +} + +function iso(value: Date | null): string | null { + return value?.toISOString() ?? null; +} + +export function scheduleItemFromRow( + row: ScheduleItemRow, + now = new Date() +): ScheduleItem { + return { + id: row.id, + title: row.title, + description: row.description, + scheduledStartAt: row.scheduledStartAt.toISOString(), + scheduledEndAt: iso(row.scheduledEndAt), + timezone: row.timezone, + priority: row.priority, + status: row.status, + nextReminderAt: iso(row.nextReminderAt), + confirmedStartAt: iso(row.confirmedStartAt), + completedAt: iso(row.completedAt), + cancelledAt: iso(row.cancelledAt), + version: row.version, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + awaitingConfirmation: + row.status === "scheduled" && row.scheduledStartAt.getTime() <= now.getTime(), + }; +} + +function reminderFromRow(row: ScheduleReminderDeliveryRow): ReminderDelivery { + return { + id: row.id, + dedupeKey: row.dedupeKey, + itemId: row.itemId, + reminderAt: row.reminderAt.toISOString(), + attemptedAt: row.attemptedAt.toISOString(), + completedAt: iso(row.completedAt), + status: row.status, + channelResults: row.channelResults, + failure: row.failure, + }; +} + +export function reminderDedupeKey(itemId: string, reminderAt: Date): string { + return `schedule:${itemId}:${reminderAt.toISOString()}`; +} + +export class ScheduleStore { + private readonly sql; + private readonly db; + private closed = false; + + constructor(databaseUrl: string) { + this.sql = postgres(databaseUrl); + this.db = drizzle(this.sql); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.sql.end(); + } + + async create(input: CreateScheduleItemInput, now = new Date()): Promise { + const [created] = await this.db + .insert(scheduleItems) + .values({ + id: nanoid(12), + ...input, + status: "scheduled", + version: 1, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (!created) throw new Error("schedule item was not written"); + return scheduleItemFromRow(created, now); + } + + async get(id: string, now = new Date()): Promise { + const row = await this.getRow(id); + return row ? scheduleItemFromRow(row, now) : null; + } + + private async getRow(id: string): Promise { + const [row] = await this.db + .select() + .from(scheduleItems) + .where(eq(scheduleItems.id, id)); + return row ?? null; + } + + async list(filter: ScheduleListFilter = {}, now = new Date()): Promise { + const predicates: SQL[] = []; + if (filter.to) predicates.push(lt(scheduleItems.scheduledStartAt, filter.to)); + if (filter.from) { + predicates.push(or( + gt(scheduleItems.scheduledEndAt, filter.from), + and( + isNull(scheduleItems.scheduledEndAt), + gte(scheduleItems.scheduledStartAt, filter.from) + ) + )!); + } + if (filter.statuses?.length) { + predicates.push(inArray(scheduleItems.status, filter.statuses)); + } + const rows = await this.db + .select() + .from(scheduleItems) + .where(predicates.length ? and(...predicates) : undefined) + .orderBy(asc(scheduleItems.scheduledStartAt), desc(scheduleItems.priority)); + return rows.map((row) => scheduleItemFromRow(row, now)); + } + + async edit( + id: string, + expectedVersion: number, + changes: EditScheduleItemInput, + now = new Date() + ): Promise { + const explicitlyEditsReminder = Object.hasOwn(changes, "nextReminderAt"); + const nextReminderAt = explicitlyEditsReminder + ? changes.nextReminderAt + : changes.scheduledStartAt + ? sql`CASE + WHEN ${scheduleItems.nextReminderAt} = ${scheduleItems.scheduledStartAt} + THEN ${changes.scheduledStartAt.toISOString()}::timestamptz + ELSE ${scheduleItems.nextReminderAt} + END` + : undefined; + const [updated] = await this.db + .update(scheduleItems) + .set({ + ...changes, + ...(nextReminderAt === undefined ? {} : { nextReminderAt }), + version: sql`${scheduleItems.version} + 1`, + updatedAt: now, + }) + .where(and( + eq(scheduleItems.id, id), + eq(scheduleItems.version, expectedVersion), + eq(scheduleItems.status, "scheduled") + )) + .returning(); + if (!updated) await this.throwMutationFailure(id, expectedVersion); + return scheduleItemFromRow(updated!, now); + } + + async confirmStart( + id: string, + expectedVersion: number, + now = new Date() + ): Promise { + return this.transition( + id, + expectedVersion, + ["scheduled"], + { + status: "active", + confirmedStartAt: now, + nextReminderAt: null, + }, + now + ); + } + + async snooze( + id: string, + expectedVersion: number, + nextReminderAt: Date, + now = new Date() + ): Promise { + return this.transition( + id, + expectedVersion, + ["scheduled"], + { nextReminderAt }, + now + ); + } + + async complete( + id: string, + expectedVersion: number, + now = new Date() + ): Promise { + return this.transition( + id, + expectedVersion, + ["scheduled", "active"], + { status: "done", completedAt: now, nextReminderAt: null }, + now + ); + } + + async cancel( + id: string, + expectedVersion: number, + now = new Date() + ): Promise { + return this.transition( + id, + expectedVersion, + ["scheduled", "active"], + { status: "cancelled", cancelledAt: now, nextReminderAt: null }, + now + ); + } + + private async transition( + id: string, + expectedVersion: number, + statuses: ScheduleStatus[], + changes: Partial, + now: Date + ): Promise { + const [updated] = await this.db + .update(scheduleItems) + .set({ + ...changes, + version: sql`${scheduleItems.version} + 1`, + updatedAt: now, + }) + .where(and( + eq(scheduleItems.id, id), + eq(scheduleItems.version, expectedVersion), + inArray(scheduleItems.status, statuses) + )) + .returning(); + if (!updated) await this.throwMutationFailure(id, expectedVersion); + return scheduleItemFromRow(updated!, now); + } + + private async throwMutationFailure( + id: string, + expectedVersion: number + ): Promise { + const current = await this.getRow(id); + if (!current) throw new ScheduleNotFoundError(id); + throw new ScheduleConflictError(id, expectedVersion, { + currentVersion: current.version, + currentStatus: current.status, + }); + } + + async dueReminders(now = new Date(), limit = 100): Promise { + const rows = await this.db + .select() + .from(scheduleItems) + .where(and( + eq(scheduleItems.status, "scheduled"), + isNotNull(scheduleItems.nextReminderAt), + lte(scheduleItems.nextReminderAt, now), + notExists( + this.db + .select({ id: scheduleReminderDeliveries.id }) + .from(scheduleReminderDeliveries) + .where(and( + eq(scheduleReminderDeliveries.itemId, scheduleItems.id), + eq( + scheduleReminderDeliveries.reminderAt, + scheduleItems.nextReminderAt + ) + )) + ) + )) + .orderBy(asc(scheduleItems.nextReminderAt), asc(scheduleItems.id)) + .limit(limit); + return rows.map((row) => ({ + item: scheduleItemFromRow(row, now), + reminderAt: row.nextReminderAt!, + })); + } + + async claimReminder( + itemId: string, + reminderAt: Date, + attemptedAt = new Date() + ): Promise { + const id = nanoid(12); + const dedupeKey = reminderDedupeKey(itemId, reminderAt); + const reminderInstant = reminderAt.toISOString(); + const attemptedInstant = attemptedAt.toISOString(); + const inserted = await this.sql.begin(async (transaction) => { + // Lock and re-check the item so a stale due-list snapshot cannot claim a + // reminder that was already confirmed, cancelled, or snoozed. + const eligible = await transaction<{ id: string }[]>` + SELECT id + FROM schedule_items + WHERE id = ${itemId} + AND status = 'scheduled' + AND next_reminder_at = ${reminderInstant} + FOR UPDATE + `; + if (!eligible[0]) return false; + const claimed = await transaction<{ id: string }[]>` + INSERT INTO schedule_reminder_deliveries ( + id, dedupe_key, item_id, reminder_at, attempted_at, status + ) VALUES ( + ${id}, ${dedupeKey}, ${itemId}, ${reminderInstant}, ${attemptedInstant}, 'claimed' + ) + ON CONFLICT (dedupe_key) DO NOTHING + RETURNING id + `; + return Boolean(claimed[0]); + }); + return inserted ? { + id, + dedupeKey, + itemId, + reminderAt: reminderAt.toISOString(), + attemptedAt: attemptedAt.toISOString(), + completedAt: null, + status: "claimed", + channelResults: null, + failure: null, + } : null; + } + + async finishReminder( + id: string, + input: { + status: "sent" | "failed"; + channelResults: NotificationSendResult["channels"] | null; + failure: string | null; + }, + completedAt = new Date() + ): Promise { + const [updated] = await this.db + .update(scheduleReminderDeliveries) + .set({ + ...input, + failure: input.failure?.slice(0, 1_000) ?? null, + completedAt, + }) + .where(and( + eq(scheduleReminderDeliveries.id, id), + eq(scheduleReminderDeliveries.status, "claimed") + )) + .returning(); + if (!updated) throw new Error(`Reminder delivery ${id} is not claimable`); + return reminderFromRow(updated); + } + + async listReminders( + filter: { itemId?: string; limit: number } + ): Promise { + const rows = await this.db + .select() + .from(scheduleReminderDeliveries) + .where(filter.itemId + ? eq(scheduleReminderDeliveries.itemId, filter.itemId) + : undefined) + .orderBy(desc(scheduleReminderDeliveries.attemptedAt)) + .limit(filter.limit); + return rows.map(reminderFromRow); + } +} diff --git a/plugins/schedule/src/types.ts b/plugins/schedule/src/types.ts new file mode 100644 index 0000000..e8af091 --- /dev/null +++ b/plugins/schedule/src/types.ts @@ -0,0 +1,74 @@ +export type ScheduleStatus = "scheduled" | "active" | "done" | "cancelled"; + +export interface ScheduleItem { + id: string; + title: string; + description: string | null; + scheduledStartAt: string; + scheduledEndAt: string | null; + timezone: string; + priority: number; + status: ScheduleStatus; + nextReminderAt: string | null; + confirmedStartAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + version: number; + createdAt: string; + updatedAt: string; + awaitingConfirmation: boolean; +} + +export interface CreateScheduleItemInput { + title: string; + description: string | null; + scheduledStartAt: Date; + scheduledEndAt: Date | null; + timezone: string; + priority: number; + nextReminderAt: Date | null; +} + +export interface EditScheduleItemInput { + title?: string; + description?: string | null; + scheduledStartAt?: Date; + scheduledEndAt?: Date | null; + timezone?: string; + priority?: number; + nextReminderAt?: Date | null; +} + +export type NotificationChannelResult = + | { status: "sent" } + | { status: "disabled" } + | { status: "failed"; error: string }; + +export interface NotificationSendResult { + channels: Record<"mac" | "ntfy", NotificationChannelResult>; +} + +/** Package-local consumer mirror of the Host's notifications.send contract. */ +export type NotificationSend = ( + request: { title: string; message: string }, + signal?: AbortSignal +) => Promise; + +export type ReminderDeliveryStatus = "claimed" | "sent" | "failed"; + +export interface ReminderDelivery { + id: string; + dedupeKey: string; + itemId: string; + reminderAt: string; + attemptedAt: string; + completedAt: string | null; + status: ReminderDeliveryStatus; + channelResults: NotificationSendResult["channels"] | null; + failure: string | null; +} + +export interface ScheduleConflictMetadata { + currentVersion: number; + currentStatus: ScheduleStatus; +} diff --git a/plugins/schedule/src/validation.ts b/plugins/schedule/src/validation.ts new file mode 100644 index 0000000..669366b --- /dev/null +++ b/plugins/schedule/src/validation.ts @@ -0,0 +1,386 @@ +import type { + CreateScheduleItemInput, + EditScheduleItemInput, + ScheduleStatus, +} from "./types.js"; + +const EXPLICIT_INSTANT_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; +const ITEM_ID_RE = /^[A-Za-z0-9_-]{8,32}$/; +const STATUSES = new Set([ + "scheduled", + "active", + "done", + "cancelled", +]); + +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; error: string }; + +function objectBody(body: unknown): ValidationResult> { + return body != null && typeof body === "object" && !Array.isArray(body) + ? { ok: true, value: body as Record } + : { ok: false, error: "body must be an object" }; +} + +function rejectUnknown( + value: Record, + allowed: readonly string[], + label = "body" +): string | null { + const allowedSet = new Set(allowed); + const unknown = Object.keys(value).find((key) => !allowedSet.has(key)); + return unknown ? `unknown ${label} field: ${unknown}` : null; +} + +function parseNonEmptyString( + value: unknown, + field: string, + maximum: number +): ValidationResult { + if (typeof value !== "string" || !value.trim() || value.length > maximum) { + return { + ok: false, + error: `${field} must be a non-empty string at most ${maximum} characters`, + }; + } + return { ok: true, value: value.trim() }; +} + +function parseNullableDescription(value: unknown): ValidationResult { + if (value === null) return { ok: true, value: null }; + if (typeof value !== "string" || value.length > 5_000) { + return { + ok: false, + error: "description must be null or a string at most 5000 characters", + }; + } + return { ok: true, value }; +} + +export function parseExplicitInstant( + value: unknown, + field: string +): ValidationResult { + const match = typeof value === "string" ? EXPLICIT_INSTANT_RE.exec(value) : null; + if (!match) { + return { + ok: false, + error: `${field} must be an ISO datetime with Z or an explicit numeric offset`, + }; + } + const [year, month, day, hour, minute, second, offsetHour, offsetMinute] = + match.slice(1).map((part) => part === undefined ? undefined : Number(part)); + const daysInMonth = new Date(Date.UTC(year!, month!, 0)).getUTCDate(); + if ( + year! < 1 || + month! < 1 || month! > 12 || + day! < 1 || day! > daysInMonth || + hour! > 23 || minute! > 59 || second! > 59 || + (offsetHour !== undefined && offsetHour > 23) || + (offsetMinute !== undefined && offsetMinute > 59) + ) { + return { ok: false, error: `${field} must be a valid datetime` }; + } + const timestamp = Date.parse(value as string); + if (!Number.isFinite(timestamp)) { + return { ok: false, error: `${field} must be a valid datetime` }; + } + return { ok: true, value: new Date(timestamp) }; +} + +function parseNullableInstant( + value: unknown, + field: string +): ValidationResult { + return value === null + ? { ok: true, value: null } + : parseExplicitInstant(value, field); +} + +function parseTimezone(value: unknown): ValidationResult { + const parsed = parseNonEmptyString(value, "timezone", 100); + if (!parsed.ok) return parsed; + try { + new Intl.DateTimeFormat("en-US", { timeZone: parsed.value }).format(); + } catch { + return { ok: false, error: "timezone must be a valid IANA timezone" }; + } + return parsed; +} + +function parsePriority(value: unknown): ValidationResult { + if (!Number.isInteger(value) || Number(value) < -1_000 || Number(value) > 1_000) { + return { + ok: false, + error: "priority must be an integer from -1000 to 1000", + }; + } + return { ok: true, value: Number(value) }; +} + +function parseExpectedVersion(value: unknown): ValidationResult { + if (!Number.isInteger(value) || Number(value) < 1) { + return { ok: false, error: "expectedVersion must be a positive integer" }; + } + return { ok: true, value: Number(value) }; +} + +export function validateScheduleInterval( + start: Date, + end: Date | null +): string | null { + return end && end.getTime() <= start.getTime() + ? "scheduledEndAt must be later than scheduledStartAt" + : null; +} + +export function validateCreateScheduleItem( + body: unknown +): ValidationResult { + const parsedBody = objectBody(body); + if (!parsedBody.ok) return parsedBody; + const value = parsedBody.value; + const unknown = rejectUnknown(value, [ + "title", + "description", + "scheduledStartAt", + "scheduledEndAt", + "timezone", + "priority", + "nextReminderAt", + ]); + if (unknown) return { ok: false, error: unknown }; + + const title = parseNonEmptyString(value.title, "title", 200); + if (!title.ok) return title; + const description = value.description === undefined + ? { ok: true as const, value: null } + : parseNullableDescription(value.description); + if (!description.ok) return description; + const scheduledStartAt = parseExplicitInstant( + value.scheduledStartAt, + "scheduledStartAt" + ); + if (!scheduledStartAt.ok) return scheduledStartAt; + const scheduledEndAt = value.scheduledEndAt === undefined + ? { ok: true as const, value: null } + : parseNullableInstant(value.scheduledEndAt, "scheduledEndAt"); + if (!scheduledEndAt.ok) return scheduledEndAt; + const intervalError = validateScheduleInterval( + scheduledStartAt.value, + scheduledEndAt.value + ); + if (intervalError) return { ok: false, error: intervalError }; + const timezone = parseTimezone(value.timezone); + if (!timezone.ok) return timezone; + const priority = value.priority === undefined + ? { ok: true as const, value: 0 } + : parsePriority(value.priority); + if (!priority.ok) return priority; + const nextReminderAt = value.nextReminderAt === undefined + ? { ok: true as const, value: scheduledStartAt.value } + : parseNullableInstant(value.nextReminderAt, "nextReminderAt"); + if (!nextReminderAt.ok) return nextReminderAt; + + return { + ok: true, + value: { + title: title.value, + description: description.value, + scheduledStartAt: scheduledStartAt.value, + scheduledEndAt: scheduledEndAt.value, + timezone: timezone.value, + priority: priority.value, + nextReminderAt: nextReminderAt.value, + }, + }; +} + +export function validateEditScheduleItem(body: unknown): ValidationResult<{ + expectedVersion: number; + changes: EditScheduleItemInput; +}> { + const parsedBody = objectBody(body); + if (!parsedBody.ok) return parsedBody; + const value = parsedBody.value; + const editable = [ + "title", + "description", + "scheduledStartAt", + "scheduledEndAt", + "timezone", + "priority", + "nextReminderAt", + ] as const; + const unknown = rejectUnknown(value, ["expectedVersion", ...editable]); + if (unknown) return { ok: false, error: unknown }; + const expectedVersion = parseExpectedVersion(value.expectedVersion); + if (!expectedVersion.ok) return expectedVersion; + if (!editable.some((field) => Object.hasOwn(value, field))) { + return { ok: false, error: "at least one editable field is required" }; + } + + const changes: EditScheduleItemInput = {}; + if (Object.hasOwn(value, "title")) { + const result = parseNonEmptyString(value.title, "title", 200); + if (!result.ok) return result; + changes.title = result.value; + } + if (Object.hasOwn(value, "description")) { + const result = parseNullableDescription(value.description); + if (!result.ok) return result; + changes.description = result.value; + } + if (Object.hasOwn(value, "scheduledStartAt")) { + const result = parseExplicitInstant(value.scheduledStartAt, "scheduledStartAt"); + if (!result.ok) return result; + changes.scheduledStartAt = result.value; + } + if (Object.hasOwn(value, "scheduledEndAt")) { + const result = parseNullableInstant(value.scheduledEndAt, "scheduledEndAt"); + if (!result.ok) return result; + changes.scheduledEndAt = result.value; + } + if (Object.hasOwn(value, "timezone")) { + const result = parseTimezone(value.timezone); + if (!result.ok) return result; + changes.timezone = result.value; + } + if (Object.hasOwn(value, "priority")) { + const result = parsePriority(value.priority); + if (!result.ok) return result; + changes.priority = result.value; + } + if (Object.hasOwn(value, "nextReminderAt")) { + const result = parseNullableInstant(value.nextReminderAt, "nextReminderAt"); + if (!result.ok) return result; + changes.nextReminderAt = result.value; + } + if (changes.scheduledStartAt && Object.hasOwn(changes, "scheduledEndAt")) { + const intervalError = validateScheduleInterval( + changes.scheduledStartAt, + changes.scheduledEndAt ?? null + ); + if (intervalError) return { ok: false, error: intervalError }; + } + return { ok: true, value: { expectedVersion: expectedVersion.value, changes } }; +} + +export function validateExpectedVersionBody( + body: unknown +): ValidationResult<{ expectedVersion: number }> { + const parsedBody = objectBody(body); + if (!parsedBody.ok) return parsedBody; + const unknown = rejectUnknown(parsedBody.value, ["expectedVersion"]); + if (unknown) return { ok: false, error: unknown }; + const expectedVersion = parseExpectedVersion(parsedBody.value.expectedVersion); + return expectedVersion.ok + ? { ok: true, value: { expectedVersion: expectedVersion.value } } + : expectedVersion; +} + +export function validateSnoozeBody(body: unknown): ValidationResult<{ + expectedVersion: number; + nextReminderAt: Date; +}> { + const parsedBody = objectBody(body); + if (!parsedBody.ok) return parsedBody; + const unknown = rejectUnknown(parsedBody.value, [ + "expectedVersion", + "nextReminderAt", + ]); + if (unknown) return { ok: false, error: unknown }; + const expectedVersion = parseExpectedVersion(parsedBody.value.expectedVersion); + if (!expectedVersion.ok) return expectedVersion; + const nextReminderAt = parseExplicitInstant( + parsedBody.value.nextReminderAt, + "nextReminderAt" + ); + if (!nextReminderAt.ok) return nextReminderAt; + return { + ok: true, + value: { + expectedVersion: expectedVersion.value, + nextReminderAt: nextReminderAt.value, + }, + }; +} + +export function validateItemId(id: string): string | null { + return ITEM_ID_RE.test(id) ? null : "schedule item id is invalid"; +} + +function queryObject(query: unknown): Record { + return query && typeof query === "object" && !Array.isArray(query) + ? query as Record + : {}; +} + +export function validateListQuery(query: unknown): ValidationResult<{ + from?: Date; + to?: Date; + statuses?: ScheduleStatus[]; +}> { + const value = queryObject(query); + const unknown = rejectUnknown(value, ["from", "to", "status"], "query"); + if (unknown) return { ok: false, error: unknown }; + const result: { from?: Date; to?: Date; statuses?: ScheduleStatus[] } = {}; + if (value.from !== undefined) { + const from = parseExplicitInstant(value.from, "from"); + if (!from.ok) return from; + result.from = from.value; + } + if (value.to !== undefined) { + const to = parseExplicitInstant(value.to, "to"); + if (!to.ok) return to; + result.to = to.value; + } + if (result.from && result.to && result.from >= result.to) { + return { ok: false, error: "to must be later than from" }; + } + if (value.status !== undefined) { + if (typeof value.status !== "string" || !value.status) { + return { ok: false, error: "status must be a comma-separated status list" }; + } + const statuses = value.status.split(",") as ScheduleStatus[]; + if ( + statuses.length > STATUSES.size || + new Set(statuses).size !== statuses.length || + statuses.some((status) => !STATUSES.has(status)) + ) { + return { + ok: false, + error: "status values must be scheduled, active, done, or cancelled", + }; + } + result.statuses = statuses; + } + return { ok: true, value: result }; +} + +export function validateReminderQuery(query: unknown): ValidationResult<{ + itemId?: string; + limit: number; +}> { + const value = queryObject(query); + const unknown = rejectUnknown(value, ["itemId", "limit"], "query"); + if (unknown) return { ok: false, error: unknown }; + if (value.itemId !== undefined) { + if (typeof value.itemId !== "string" || validateItemId(value.itemId)) { + return { ok: false, error: "itemId is invalid" }; + } + } + const limit = value.limit === undefined ? 100 : Number(value.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 500) { + return { ok: false, error: "limit must be an integer from 1 to 500" }; + } + return { + ok: true, + value: { + ...(typeof value.itemId === "string" ? { itemId: value.itemId } : {}), + limit, + }, + }; +} diff --git a/plugins/schedule/tsconfig.json b/plugins/schedule/tsconfig.json new file mode 100644 index 0000000..246146a --- /dev/null +++ b/plugins/schedule/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/plugins/schedule/tsup.config.ts b/plugins/schedule/tsup.config.ts new file mode 100644 index 0000000..e285e02 --- /dev/null +++ b/plugins/schedule/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + format: "esm", + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/plugins/schedule/web/index.js b/plugins/schedule/web/index.js new file mode 100644 index 0000000..2220329 --- /dev/null +++ b/plugins/schedule/web/index.js @@ -0,0 +1,469 @@ +const SCHEDULE_STYLE_HREF = "/plugins/schedule/styles.css"; +const SCHEDULE_FACE_TYPES = new Set([ + "schedule-overview", + "schedule-month", + "schedule-week", + "schedule-day", +]); +const VALID_STATUSES = new Set(["scheduled", "active", "done", "cancelled"]); +const ACTION_SURFACES = new Set(["overview", "day"]); +const STATUS_LABELS = { + scheduled: "待时", + awaiting: "待确认", + active: "进行中", + done: "已完成", + cancelled: "已取消", +}; +const WEEKDAY_LABELS = ["一", "二", "三", "四", "五", "六", "日"]; + +function dateFromKey(key) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) throw new Error(`invalid date key: ${key}`); + const date = new Date(`${key}T00:00:00.000Z`); + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== key) { + throw new Error(`invalid date key: ${key}`); + } + return date; +} + +function keyFromDate(date) { + return date.toISOString().slice(0, 10); +} + +function addDays(key, amount) { + const date = dateFromKey(key); + date.setUTCDate(date.getUTCDate() + amount); + return keyFromDate(date); +} + +function localDateKey(date) { + return [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((part, index) => String(part).padStart(index === 0 ? 4 : 2, "0")) + .join("-"); +} + +function startOfWeek(key) { + const day = dateFromKey(key).getUTCDay(); + return addDays(key, -((day + 6) % 7)); +} + +function rangeForView(view, referenceKey) { + dateFromKey(referenceKey); + let from; + let length; + if (view === "month") { + from = startOfWeek(`${referenceKey.slice(0, 7)}-01`); + length = 42; + } else if (view === "week") { + from = startOfWeek(referenceKey); + length = 7; + } else if (view === "day") { + from = referenceKey; + length = 1; + } else { + throw new Error(`unknown schedule view: ${view}`); + } + const keys = Array.from({ length }, (_, index) => addDays(from, index)); + return { from, to: addDays(from, length), keys }; +} + +function queryWindow(referenceKey) { + const month = rangeForView("month", referenceKey); + return { + from: `${addDays(month.from, -2)}T00:00:00.000Z`, + to: `${addDays(month.to, 2)}T00:00:00.000Z`, + }; +} + +function partsInTimezone(instant, timezone) { + const date = new Date(instant); + if (Number.isNaN(date.getTime())) throw new Error(`invalid instant: ${instant}`); + const parts = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + const value = (type) => parts.find((part) => part.type === type)?.value; + return { year: value("year"), month: value("month"), day: value("day") }; +} + +function dateKeyInTimezone(instant, timezone) { + const { year, month, day } = partsInTimezone(instant, timezone); + return `${year}-${month}-${day}`; +} + +function itemDateSpan(item) { + const startMs = Date.parse(item.scheduledStartAt); + if (!Number.isFinite(startMs)) return null; + const startKey = dateKeyInTimezone(startMs, item.timezone); + const parsedEnd = item.scheduledEndAt == null ? startMs : Date.parse(item.scheduledEndAt); + const endMs = Number.isFinite(parsedEnd) && parsedEnd > startMs ? parsedEnd - 1 : startMs; + return { + startKey, + endKey: dateKeyInTimezone(endMs, item.timezone), + }; +} + +function compareItems(left, right) { + const byStart = Date.parse(left.scheduledStartAt) - Date.parse(right.scheduledStartAt); + if (byStart !== 0) return byStart; + const byPriority = Number(right.priority || 0) - Number(left.priority || 0); + if (byPriority !== 0) return byPriority; + return String(left.id).localeCompare(String(right.id)); +} + +function groupItems(items, view, referenceKey) { + const range = rangeForView(view, referenceKey); + const groups = new Map(range.keys.map((key) => [key, []])); + for (const item of Array.isArray(items) ? items : []) { + let span; + try { + span = itemDateSpan(item); + } catch { + continue; + } + if (!span) continue; + for (const key of range.keys) { + if (key >= span.startKey && key <= span.endKey) groups.get(key).push(item); + } + } + for (const entries of groups.values()) entries.sort(compareItems); + return { ...range, groups }; +} + +function normalizedStatus(item) { + return VALID_STATUSES.has(item?.status) ? item.status : "scheduled"; +} + +function displayStatus(item, now = new Date()) { + const status = normalizedStatus(item); + if (status === "scheduled" && Date.parse(item.scheduledStartAt) <= now.getTime()) { + return "awaiting"; + } + return status; +} + +function actionTarget(surface, itemId) { + if (!ACTION_SURFACES.has(surface)) throw new Error(`unknown schedule action surface: ${surface}`); + return `${surface}:${encodeURIComponent(String(itemId))}`; +} + +function parseActionTarget(value) { + const target = String(value ?? ""); + const separator = target.indexOf(":"); + const surface = separator >= 0 ? target.slice(0, separator) : ""; + if (!ACTION_SURFACES.has(surface)) return { itemId: target, target }; + try { + return { itemId: decodeURIComponent(target.slice(separator + 1)), target }; + } catch { + return null; + } +} + +function formatItemTime(item, locale = "zh-CN") { + try { + const options = { + timeZone: item.timezone, + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZoneName: "short", + }; + const start = new Intl.DateTimeFormat(locale, options).format(new Date(item.scheduledStartAt)); + if (!item.scheduledEndAt) return start; + const end = new Intl.DateTimeFormat(locale, options).format(new Date(item.scheduledEndAt)); + return `${start}–${end}`; + } catch { + return String(item.scheduledStartAt || ""); + } +} + +function mountStylesheet(root) { + const documentRef = root?.ownerDocument ?? globalThis.document; + if (!documentRef?.createElement) return null; + const link = documentRef.createElement("link"); + link.rel = "stylesheet"; + link.href = SCHEDULE_STYLE_HREF; + link.dataset.echologPluginStyle = "schedule"; + (documentRef.head ?? root)?.appendChild(link); + return link; +} + +function renderStatus(item, now, esc) { + const status = displayStatus(item, now); + return `${esc(STATUS_LABELS[status])}`; +} + +function renderActions(item, { escA }, surface) { + const target = actionTarget(surface, item.id); + const escapedTarget = escA(target); + const status = normalizedStatus(item); + if (status === "done" || status === "cancelled") return ""; + const complete = ``; + const cancel = ``; + if (status === "active") { + return `
${complete}${cancel}
`; + } + return `
+ + + + ${complete}${cancel} +
`; +} + +function renderItem(item, context, now, { compact = false, surface = null } = {}) { + const { esc, escA } = context; + const description = !compact && item.description + ? `

${esc(item.description)}

` + : ""; + const actions = compact ? "" : renderActions(item, context, surface); + return `
+
+ ${esc(item.title)} + ${renderStatus(item, now, esc)} +
+
${esc(formatItemTime(item))} · ${esc(item.timezone)}
+ ${description} + ${actions} +
`; +} + +function renderCreateForm(context) { + const { escA } = context; + const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + return `
+
添一程
+
+ + + + + + +
+
+ +
`; +} + +function renderOverview(items, context, now) { + const actionable = items + .filter((item) => ["scheduled", "active"].includes(normalizedStatus(item))) + .sort(compareItems) + .slice(0, 8); + return `
+
日 程
+
到时只作提醒;唯有你确认,方才开始。
+ ${renderCreateForm(context)} +
+
+ ${actionable.map((item) => renderItem(item, context, now, { surface: "overview" })).join("") || '

近日无待办日程。

'} +
+
`; +} + +function renderMonth(items, referenceKey, context, now) { + const { esc, escA } = context; + const calendar = groupItems(items, "month", referenceKey); + const currentMonth = referenceKey.slice(0, 7); + const cells = calendar.keys.map((key) => { + const entries = calendar.groups.get(key); + const outside = key.slice(0, 7) !== currentMonth ? " is-outside" : ""; + const rendered = entries.slice(0, 3).map((item) => renderItem(item, context, now, { compact: true })).join(""); + const rest = entries.length > 3 ? `另 ${esc(entries.length - 3)} 项` : ""; + return `
+ ${esc(Number(key.slice(8, 10)))}${rendered}${rest} +
`; + }).join(""); + return `
+
月 览
+
${esc(currentMonth)} · 每项依自身时区归日
+
${WEEKDAY_LABELS.map((label) => `${label}`).join("")}
+
${cells}
+
`; +} + +function renderWeek(items, referenceKey, context, now) { + const { esc, escA } = context; + const calendar = groupItems(items, "week", referenceKey); + const days = calendar.keys.map((key, index) => { + const entries = calendar.groups.get(key); + return `
+
周${WEEKDAY_LABELS[index]}${esc(key.slice(5))}
+
${entries.map((item) => renderItem(item, context, now, { compact: true })).join("") || ''}
+
`; + }).join(""); + return `
+
周 览
+
${esc(calendar.from)} — ${esc(addDays(calendar.to, -1))}
+
${days}
+
`; +} + +function renderDay(items, referenceKey, context, now) { + const { esc } = context; + const calendar = groupItems(items, "day", referenceKey); + const entries = calendar.groups.get(referenceKey); + return `
+
日 览
+
${esc(referenceKey)} · 每项显示其 IANA 时区
+
+
+ ${entries.map((item) => renderItem(item, context, now, { surface: "day" })).join("") || '

今日无日程。

'} +
+
`; +} + +function explicitOffsetInstant(value) { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) + && Number.isFinite(Date.parse(value)); +} + +function validTimezone(value) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(); + return true; + } catch { + return false; + } +} + +export async function activate({ api, root, now: nowFactory = () => new Date() }) { + const stylesheet = mountStylesheet(root); + let referenceKey = localDateKey(nowFactory()); + let latestItems = []; + + const setError = ($, id, error) => { + const element = $(id) ?? $("scheduleActionError") ?? $("scheduleActionErrorDay"); + if (element) element.textContent = error instanceof Error ? error.message : String(error || ""); + }; + + const replaceLatest = (updated) => { + if (!updated?.id) return; + latestItems = latestItems.map((item) => item.id === updated.id ? updated : item); + }; + + return { + id: "schedule", + async load() { + referenceKey = localDateKey(nowFactory()); + const window = queryWindow(referenceKey); + const path = `/plugins/schedule/items?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`; + const result = await api(path); + if (!Array.isArray(result)) throw new Error("Schedule items response must be an array"); + latestItems = result; + return { scheduleItems: result, scheduleCalendar: { referenceKey, ...window } }; + }, + faces() { + return [...SCHEDULE_FACE_TYPES].map((type) => ({ type })); + }, + renderFace(face, context) { + if (!SCHEDULE_FACE_TYPES.has(face?.type)) return null; + const items = Array.isArray(context.data?.scheduleItems) + ? context.data.scheduleItems + : latestItems; + const renderNow = nowFactory(); + if (face.type === "schedule-overview") return renderOverview(items, context, renderNow); + if (face.type === "schedule-month") return renderMonth(items, referenceKey, context, renderNow); + if (face.type === "schedule-week") return renderWeek(items, referenceKey, context, renderNow); + return renderDay(items, referenceKey, context, renderNow); + }, + async handleAction(action, { id, $, confirm }) { + if (action === "schedule-ignore") return { handled: true, refresh: false }; + if (action === "schedule-create") { + const title = ($("scheduleTitle")?.value ?? "").trim(); + const description = ($("scheduleDescription")?.value ?? "").trim(); + const scheduledStartAt = ($("scheduleStart")?.value ?? "").trim(); + const scheduledEndInput = ($("scheduleEnd")?.value ?? "").trim(); + const timezone = ($("scheduleTimezone")?.value ?? "").trim(); + const priority = Number($("schedulePriority")?.value ?? 0); + let error = ""; + if (!title) error = "日程标题不可为空。"; + else if (!explicitOffsetInstant(scheduledStartAt)) error = "开始时刻须含 Z 或明确偏移。"; + else if (scheduledEndInput && !explicitOffsetInstant(scheduledEndInput)) error = "结束时刻须含 Z 或明确偏移。"; + else if (scheduledEndInput && Date.parse(scheduledEndInput) <= Date.parse(scheduledStartAt)) error = "结束时刻须晚于开始时刻。"; + else if (!validTimezone(timezone)) error = "请填写有效的 IANA 时区。"; + else if (!Number.isInteger(priority) || priority < -1000 || priority > 1000) error = "优先级须为 -1000 至 1000 的整数。"; + if (error) { + setError($, "scheduleCreateError", error); + return { handled: true, refresh: false }; + } + try { + await api("/plugins/schedule/items", { + method: "POST", + body: JSON.stringify({ + title, + description: description || null, + scheduledStartAt, + scheduledEndAt: scheduledEndInput || null, + timezone, + priority, + }), + }); + setError($, "scheduleCreateError", ""); + return { handled: true, message: "日程已添 · 待时" }; + } catch (error) { + setError($, "scheduleCreateError", error); + return { handled: true, refresh: false }; + } + } + + const routeByAction = { + "schedule-confirm-start": "confirm-start", + "schedule-snooze": "snooze", + "schedule-complete": "complete", + "schedule-cancel": "cancel", + }; + const route = routeByAction[action]; + if (!route) return { handled: false }; + const parsedTarget = parseActionTarget(id); + if (!parsedTarget) return { handled: true, refresh: false }; + const item = latestItems.find((candidate) => candidate.id === parsedTarget.itemId); + if (!item) return { handled: true, refresh: false }; + if (action === "schedule-cancel" && !confirm("取消此日程?")) { + return { handled: true, refresh: false }; + } + const body = { expectedVersion: item.version }; + if (action === "schedule-snooze") { + const minutes = Number($(`scheduleSnooze:${parsedTarget.target}`)?.value ?? 10); + if (!Number.isInteger(minutes) || minutes < 1 || minutes > 10080) { + setError($, "scheduleActionError", "稍后提醒须为 1 至 10080 分钟。"); + return { handled: true, refresh: false }; + } + body.nextReminderAt = new Date(nowFactory().getTime() + minutes * 60_000).toISOString(); + } + try { + const updated = await api(`/plugins/schedule/items/${encodeURIComponent(parsedTarget.itemId)}/${route}`, { + method: "POST", + body: JSON.stringify(body), + }); + replaceLatest(updated); + setError($, "scheduleActionError", ""); + const message = { + "schedule-confirm-start": "已确认开始 · 行", + "schedule-snooze": "提醒已顺延", + "schedule-complete": "日程已完成 · 毕", + "schedule-cancel": "日程已取消 · 罢", + }[action]; + return { handled: true, message }; + } catch (error) { + setError($, "scheduleActionError", error); + return { handled: true, refresh: false }; + } + }, + async unmount() { + stylesheet?.remove?.(); + }, + }; +} + +export const scheduleWebTest = Object.freeze({ + addDays, + dateKeyInTimezone, + displayStatus, + groupItems, + itemDateSpan, + queryWindow, + rangeForView, +}); diff --git a/plugins/schedule/web/styles.css b/plugins/schedule/web/styles.css new file mode 100644 index 0000000..a18c580 --- /dev/null +++ b/plugins/schedule/web/styles.css @@ -0,0 +1,338 @@ +.schedule-face { + --schedule-line: color-mix(in srgb, var(--ink-faint) 30%, transparent); + --schedule-wash: color-mix(in srgb, var(--ink) 5%, transparent); + --schedule-accent-wash: color-mix(in srgb, var(--cinnabar) 9%, transparent); +} + +.schedule-face .toc-title { + margin-bottom: 0.45rem; +} + +.schedule-range-title { + margin-bottom: 0.6rem; + color: var(--ink-faint); + font-family: var(--kai); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-align: center; +} + +.schedule-create { + position: relative; + flex: none; + padding-bottom: 0.7rem; + border-bottom: 1px solid var(--schedule-line); +} + +.schedule-create .toc-section { + margin-bottom: 0.35rem; +} + +.schedule-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.3rem 0.65rem; +} + +.schedule-form-grid .form-input { + min-width: 0; + padding: 0.32rem 0.15rem; + font-size: 0.72rem; +} + +.schedule-form-grid textarea { + min-height: 2.2rem; + max-height: 3.8rem; + resize: vertical; +} + +.schedule-create-button { + position: absolute; + right: 0; + bottom: 0.4rem; +} + +.schedule-create-button .s-face { + width: 38px; + height: 38px; + font-size: 1rem; +} + +.schedule-create .form-error { + max-width: calc(100% - 5rem); + margin: 0.25rem 0 0; + text-align: left; +} + +.schedule-action-error { + flex: none; + margin: 0.25rem 0; +} + +.schedule-agenda { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.schedule-overview-face .schedule-agenda { + padding-top: 0.2rem; +} + +.schedule-item { + flex: none; + min-width: 0; + padding: 0.55rem 0.65rem; + border: 1px solid var(--schedule-line); + border-left: 3px solid var(--ink-faint); + border-radius: 5px; + background: var(--schedule-wash); +} + +.schedule-item.schedule-awaiting { + border-left-color: var(--cinnabar); + background: var(--schedule-accent-wash); +} + +.schedule-item.schedule-active { + border-left-color: var(--gold); +} + +.schedule-item.schedule-done, +.schedule-item.schedule-cancelled { + opacity: 0.66; +} + +.schedule-item-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.45rem; +} + +.schedule-item-head strong { + min-width: 0; + overflow: hidden; + color: var(--ink); + font-family: var(--kai); + font-size: 0.88rem; + font-weight: 600; + letter-spacing: 0.04em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-state { + flex: none; + padding: 0.08rem 0.35rem; + border: 1px solid var(--schedule-line); + border-radius: 999px; + color: var(--ink-faint); + font-size: 0.62rem; + letter-spacing: 0.08em; +} + +.schedule-state.is-awaiting { + border-color: var(--cinnabar); + color: var(--cinnabar); +} + +.schedule-state.is-active { + border-color: var(--gold); + color: var(--ink); +} + +.schedule-time { + margin-top: 0.18rem; + overflow: hidden; + color: var(--ink-faint); + font-size: 0.64rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-description { + margin-top: 0.32rem; + color: var(--ink-soft); + font-size: 0.72rem; + line-height: 1.45; +} + +.schedule-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-top: 0.45rem; +} + +.schedule-actions button { + padding: 0.2rem 0.48rem; + border: 1px solid var(--schedule-line); + border-radius: 999px; + color: var(--ink-soft); + background: transparent; + font-family: var(--kai); + font-size: 0.66rem; + cursor: pointer; +} + +.schedule-actions button:hover, +.schedule-actions button:focus-visible { + border-color: var(--cinnabar); + color: var(--cinnabar); +} + +.schedule-actions .schedule-primary { + border-color: var(--cinnabar); + color: var(--cinnabar); +} + +.schedule-snooze { + display: inline-flex; + align-items: center; + gap: 0.2rem; + color: var(--ink-faint); + font-size: 0.62rem; +} + +.schedule-snooze input { + width: 3.2rem; + padding: 0.16rem 0.25rem; + border: 1px solid var(--schedule-line); + border-radius: 3px; + color: var(--ink); + background: transparent; + font: inherit; +} + +.schedule-weekdays, +.schedule-month-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.schedule-weekdays { + flex: none; + color: var(--cinnabar); + font-family: var(--kai); + font-size: 0.65rem; + text-align: center; +} + +.schedule-month-grid { + flex: 1; + min-height: 0; + grid-template-rows: repeat(6, minmax(0, 1fr)); + border-top: 1px solid var(--schedule-line); + border-left: 1px solid var(--schedule-line); +} + +.schedule-month-day { + min-width: 0; + overflow: hidden; + padding: 0.2rem; + border-right: 1px solid var(--schedule-line); + border-bottom: 1px solid var(--schedule-line); +} + +.schedule-month-day.is-outside { + opacity: 0.42; +} + +.schedule-date-number { + display: block; + margin-bottom: 0.12rem; + color: var(--ink-faint); + font-size: 0.58rem; + font-variant-numeric: tabular-nums; +} + +.schedule-month-day .schedule-item { + margin-bottom: 0.12rem; + padding: 0.12rem 0.18rem; + border-width: 0 0 0 2px; + border-radius: 0; + background: transparent; +} + +.schedule-month-day .schedule-item-head strong { + font-size: 0.56rem; +} + +.schedule-month-day .schedule-state, +.schedule-month-day .schedule-time { + display: none; +} + +.schedule-more { + display: block; + color: var(--ink-faint); + font-size: 0.52rem; +} + +.schedule-week-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.schedule-week-day { + display: grid; + grid-template-columns: 4rem minmax(0, 1fr); + gap: 0.55rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid var(--schedule-line); +} + +.schedule-week-date { + display: flex; + flex-direction: column; + color: var(--cinnabar); + font-family: var(--kai); + font-size: 0.65rem; +} + +.schedule-week-date strong { + color: var(--ink); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} + +.schedule-week-items { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.25rem; +} + +.schedule-week-items .schedule-item { + padding: 0.26rem 0.35rem; +} + +.schedule-week-items .schedule-item-head strong { + font-size: 0.7rem; +} + +.schedule-week-items .schedule-state { + display: none; +} + +.schedule-week-items .schedule-time { + font-size: 0.55rem; +} + +@media (max-width: 760px) { + .schedule-form-grid { + grid-template-columns: 1fr; + } + + .schedule-week-items { + grid-template-columns: 1fr; + } + + .schedule-month-day .schedule-item:nth-of-type(n + 3) { + display: none; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f31cd2e..ccf6d06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@echolog/plugin-schedule': + specifier: workspace:* + version: link:plugins/schedule '@echolog/plugin-screen-time': specifier: workspace:* version: link:plugins/screen-time @@ -85,6 +88,28 @@ importers: specifier: ^5.8.3 version: 5.9.3 + plugins/schedule: + dependencies: + '@echolog/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + drizzle-orm: + specifier: ^0.44.0 + version: 0.44.7(postgres@3.4.9) + nanoid: + specifier: ^5.1.5 + version: 5.1.11 + postgres: + specifier: ^3.4.7 + version: 3.4.9 + devDependencies: + tsup: + specifier: ^8.5.0 + version: 8.5.1(tsx@4.22.2)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + plugins/screen-time: dependencies: '@echolog/plugin-sdk': diff --git a/src/cli/index.ts b/src/cli/index.ts index b9bf254..05af805 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -834,6 +834,357 @@ withJson( }) ); +// el schedule list|show|add|edit|confirm|snooze|done|cancel +type ScheduleCliItem = { + id: string; + title: string; + description: string | null; + scheduledStartAt: string; + scheduledEndAt: string | null; + timezone: string; + priority: number; + status: "scheduled" | "active" | "done" | "cancelled"; + nextReminderAt: string | null; + confirmedStartAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + version: number; + awaitingConfirmation: boolean; +}; + +function parseScheduleExpectedVersion(value: string): number { + if (!/^[1-9]\d*$/.test(value)) { + throw new CliUsageError("--expected-version 必须是大于或等于 1 的整数"); + } + const version = Number(value); + if (!Number.isSafeInteger(version)) { + throw new CliUsageError("--expected-version 必须是安全整数"); + } + return version; +} + +function parseSchedulePriority(value: string): number { + if (!/^-?\d+$/.test(value)) { + throw new CliUsageError("--priority 必须是整数"); + } + const priority = Number(value); + if (!Number.isSafeInteger(priority)) { + throw new CliUsageError("--priority 必须是安全整数"); + } + return priority; +} + +function scheduleItemPath(id: string): string { + return `/api/plugins/schedule/items/${encodeURIComponent(id)}`; +} + +function printScheduleItem(item: ScheduleCliItem): void { + const icon = item.status === "done" + ? "✓" + : item.status === "active" + ? "▶" + : item.status === "cancelled" + ? "✗" + : item.awaitingConfirmation + ? "!" + : "○"; + console.log( + `${icon} ${item.title} [${item.id}] ${item.status} · ${item.scheduledStartAt} (${item.timezone}) · v${item.version}` + ); +} + +const schedule = program + .command("schedule") + .description("管理 Schedule 插件日程;提醒不会自动开始任务,状态只由显式命令改变。") + .addHelpText( + "after", + ` +时间格式: + 时间点必须是带 Z 或数字偏移的 ISO-8601,例如 2026-08-24T09:00:00+08:00。 + timezone 必须是 IANA 时区,例如 Asia/Shanghai;它只保存显示意图,不替代时间点偏移。 + +示例: + $ el schedule list --from 2026-08-24T00:00:00+08:00 --to 2026-08-25T00:00:00+08:00 + $ el schedule add "设计评审" --start 2026-08-24T09:00:00+08:00 --timezone Asia/Shanghai + $ el schedule confirm --expected-version 1 --json +` + ); + +withJson( + schedule + .command("list") + .description("列出日程;范围为 [from,to),status 可为 scheduled,active,done,cancelled 的逗号列表。") + .option("--from ", "范围起点,带 Z 或数字偏移的 ISO-8601") + .option("--to ", "范围终点,带 Z 或数字偏移的 ISO-8601") + .option("--status ", "状态列表: scheduled,active,done,cancelled") + .addHelpText( + "after", + ` +示例: + $ el schedule list + $ el schedule list --from 2026-08-24T00:00:00+08:00 --to 2026-08-25T00:00:00+08:00 --status scheduled,active --json +` + ) +).action( + action(async (thisCommand, opts: { from?: string; to?: string; status?: string }) => { + const query = new URLSearchParams(); + if (opts.from) query.set("from", opts.from); + if (opts.to) query.set("to", opts.to); + if (opts.status) query.set("status", opts.status); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + const items = await api(`/api/plugins/schedule/items${suffix}`); + printSuccess(thisCommand, items, () => { + if (items.length === 0) { + console.log("暂无日程"); + return; + } + for (const item of items) printScheduleItem(item); + }); + }) +); + +withJson( + schedule + .command("show ") + .description("查看一条日程;id 来自 el schedule list。") + .addHelpText( + "after", + ` +示例: + $ el schedule show + $ el schedule show --json +` + ) +).action( + action(async (thisCommand, id: string) => { + const item = await api(scheduleItemPath(id)); + printSuccess(thisCommand, item, () => { + printScheduleItem(item); + console.log(` 描述: ${item.description ?? "-"}`); + console.log(` 计划结束: ${item.scheduledEndAt ?? "-"}`); + console.log(` 下次提醒: ${item.nextReminderAt ?? "-"}`); + console.log(` 确认开始: ${item.confirmedStartAt ?? "-"}`); + console.log(` 优先级: ${item.priority}`); + }); + }) +); + +withJson( + schedule + .command("add ") + .description("新增 scheduled 日程;到达 start 只提醒,不会自动开始或创建 Core record。") + .requiredOption("--start <ISO>", "计划开始,带 Z 或数字偏移的 ISO-8601") + .requiredOption("--timezone <IANA>", "显示时区,例如 Asia/Shanghai") + .option("--description <text>", "日程描述") + .option("--end <ISO>", "计划结束,带 Z 或数字偏移的 ISO-8601") + .option("--priority <n>", "整数优先级;取值范围由服务端校验") + .option("--remind-at <ISO>", "首次提醒时间,带 Z 或数字偏移;省略时等于 start") + .option("--no-reminder", "创建时不设置提醒") + .addHelpText( + "after", + ` +示例: + $ el schedule add "设计评审" --start 2026-08-24T09:00:00+08:00 --end 2026-08-24T10:00:00+08:00 --timezone Asia/Shanghai + $ el schedule add "发布检查" --start 2026-08-24T18:00:00Z --timezone UTC --remind-at 2026-08-24T17:45:00Z --priority 2 --json +` + ) +).action( + action(async (thisCommand, title: string, opts: { + start: string; + timezone: string; + description?: string; + end?: string; + priority?: string; + remindAt?: string; + reminder?: boolean; + }) => { + if (opts.reminder === false && opts.remindAt !== undefined) { + throw new CliUsageError("--remind-at 和 --no-reminder 不能同时使用"); + } + const body: Record<string, unknown> = { + title, + scheduledStartAt: opts.start, + timezone: opts.timezone, + }; + if (opts.description !== undefined) body.description = opts.description; + if (opts.end !== undefined) body.scheduledEndAt = opts.end; + if (opts.priority !== undefined) body.priority = parseSchedulePriority(opts.priority); + if (opts.remindAt !== undefined) body.nextReminderAt = opts.remindAt; + if (opts.reminder === false) body.nextReminderAt = null; + + const item = await post<ScheduleCliItem>("/api/plugins/schedule/items", body); + printSuccess(thisCommand, item, () => { + console.log(`✓ 已添加日程: ${item.title} [${item.id}] · v${item.version}`); + }); + }) +); + +withJson( + schedule + .command("edit <id>") + .description("编辑仍为 scheduled 的日程;必须携带当前 expectedVersion,冲突由服务端返回 409。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + .option("--title <title>", "新标题") + .option("--description <text>", "新描述") + .option("--clear-description", "将描述设为 null") + .option("--start <ISO>", "计划开始,带 Z 或数字偏移的 ISO-8601") + .option("--end <ISO>", "计划结束,带 Z 或数字偏移的 ISO-8601") + .option("--clear-end", "将计划结束设为 null") + .option("--timezone <IANA>", "显示时区,例如 Asia/Shanghai") + .option("--priority <n>", "整数优先级;取值范围由服务端校验") + .option("--remind-at <ISO>", "下次提醒时间,带 Z 或数字偏移") + .option("--clear-reminder", "将下次提醒设为 null") + .addHelpText( + "after", + ` +示例: + $ el schedule edit <id> --expected-version 1 --title "设计评审(更新)" + $ el schedule edit <id> --expected-version 2 --start 2026-08-24T10:00:00+08:00 --timezone Asia/Shanghai --json + $ el schedule edit <id> --expected-version 3 --clear-end --clear-reminder +` + ) +).action( + action(async (thisCommand, id: string, opts: { + expectedVersion: string; + title?: string; + description?: string; + clearDescription?: boolean; + start?: string; + end?: string; + clearEnd?: boolean; + timezone?: string; + priority?: string; + remindAt?: string; + clearReminder?: boolean; + }) => { + if (opts.description !== undefined && opts.clearDescription) { + throw new CliUsageError("--description 和 --clear-description 不能同时使用"); + } + if (opts.end !== undefined && opts.clearEnd) { + throw new CliUsageError("--end 和 --clear-end 不能同时使用"); + } + if (opts.remindAt !== undefined && opts.clearReminder) { + throw new CliUsageError("--remind-at 和 --clear-reminder 不能同时使用"); + } + const body: Record<string, unknown> = { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + }; + if (opts.title !== undefined) body.title = opts.title; + if (opts.description !== undefined) body.description = opts.description; + if (opts.clearDescription) body.description = null; + if (opts.start !== undefined) body.scheduledStartAt = opts.start; + if (opts.end !== undefined) body.scheduledEndAt = opts.end; + if (opts.clearEnd) body.scheduledEndAt = null; + if (opts.timezone !== undefined) body.timezone = opts.timezone; + if (opts.priority !== undefined) body.priority = parseSchedulePriority(opts.priority); + if (opts.remindAt !== undefined) body.nextReminderAt = opts.remindAt; + if (opts.clearReminder) body.nextReminderAt = null; + + const item = await patch<ScheduleCliItem>(scheduleItemPath(id), body); + printSuccess(thisCommand, item, () => { + console.log(`✓ 已更新日程: ${item.title} [${item.id}] · v${item.version}`); + }); + }) +); + +withJson( + schedule + .command("confirm <id>") + .description("显式确认开始:scheduled -> active;confirmedStartAt 由服务端记录为确认时刻。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + .addHelpText( + "after", + ` +示例: + $ el schedule confirm <id> --expected-version 1 + $ el schedule confirm <id> --expected-version 1 --json +` + ) +).action( + action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/confirm-start`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + }); + printSuccess(thisCommand, item, () => { + console.log(`▶ 已确认开始: ${item.title} [${item.id}] · v${item.version}`); + }); + }) +); + +withJson( + schedule + .command("snooze <id>") + .description("仅移动 scheduled 日程的 nextReminderAt;不会改变状态或自动开始。") + .requiredOption("--until <ISO>", "新的提醒时间,带 Z 或数字偏移的 ISO-8601") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + .addHelpText( + "after", + ` +示例: + $ el schedule snooze <id> --until 2026-08-24T09:15:00+08:00 --expected-version 1 + $ el schedule snooze <id> --until 2026-08-24T01:15:00Z --expected-version 1 --json +` + ) +).action( + action(async (thisCommand, id: string, opts: { until: string; expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/snooze`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + nextReminderAt: opts.until, + }); + printSuccess(thisCommand, item, () => { + console.log(`⏰ 已延后提醒: ${item.title} · ${item.nextReminderAt} · v${item.version}`); + }); + }) +); + +withJson( + schedule + .command("done <id>") + .description("显式完成 scheduled 或 active 日程;不会修改任何 Core record。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + .addHelpText( + "after", + ` +示例: + $ el schedule done <id> --expected-version 2 + $ el schedule done <id> --expected-version 2 --json +` + ) +).action( + action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/complete`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + }); + printSuccess(thisCommand, item, () => { + console.log(`✓ 已完成日程: ${item.title} [${item.id}] · v${item.version}`); + }); + }) +); + +withJson( + schedule + .command("cancel <id>") + .description("显式取消 scheduled 或 active 日程;忽略提醒本身不会取消。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + .addHelpText( + "after", + ` +示例: + $ el schedule cancel <id> --expected-version 1 + $ el schedule cancel <id> --expected-version 1 --json +` + ) +).action( + action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/cancel`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + }); + printSuccess(thisCommand, item, () => { + console.log(`✗ 已取消日程: ${item.title} [${item.id}] · v${item.version}`); + }); + }) +); + // el screen [date] const screen = program .command("screen") diff --git a/src/core/plugins/registry.ts b/src/core/plugins/registry.ts index 2eca0de..44e5ecd 100644 --- a/src/core/plugins/registry.ts +++ b/src/core/plugins/registry.ts @@ -1,13 +1,18 @@ import type { PluginDefinition } from "@echolog/plugin-sdk"; +import { schedulePlugin } from "@echolog/plugin-schedule"; import { screenTimePlugin } from "@echolog/plugin-screen-time"; import { tmuxStatusPlugin } from "@echolog/plugin-tmux-status"; export const bundledPlugins: readonly PluginDefinition[] = [ + schedulePlugin, screenTimePlugin, tmuxStatusPlugin, ]; export const bundledPluginWebAssets = [{ + prefix: "/plugins/schedule/", + root: "schedule/web", +}, { prefix: "/plugins/screen-time/", root: "screen-time/web", }] as const; diff --git a/tests/schedule-cli.test.ts b/tests/schedule-cli.test.ts new file mode 100644 index 0000000..cc31eea --- /dev/null +++ b/tests/schedule-cli.test.ts @@ -0,0 +1,339 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer, type IncomingMessage } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +type CliResult = { + exitCode: number; + stdout: string; + stderr: string; +}; + +type CapturedRequest = { + method: string; + url: string; + body: unknown; +}; + +function runCli(configPath: string, args: string[]): Promise<CliResult> { + return new Promise((resolve) => { + execFile( + join(repoRoot, "node_modules/.bin/tsx"), + [join(repoRoot, "src/cli/index.ts"), ...args], + { + cwd: repoRoot, + env: { ...process.env, ECHOLOG_CONFIG_PATH: configPath }, + }, + (error, stdout, stderr) => { + resolve({ + exitCode: typeof error?.code === "number" ? error.code : 0, + stdout, + stderr, + }); + } + ); + }); +} + +async function readJsonBody(request: IncomingMessage): Promise<unknown> { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const text = Buffer.concat(chunks).toString("utf8"); + return text ? JSON.parse(text) : undefined; +} + +function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> { + return { + id: "schedule-1", + title: "设计评审", + description: "确认 API 契约", + scheduledStartAt: "2026-08-24T01:00:00.000Z", + scheduledEndAt: "2026-08-24T02:00:00.000Z", + timezone: "Asia/Shanghai", + priority: 2, + status: "scheduled", + nextReminderAt: "2026-08-24T01:00:00.000Z", + confirmedStartAt: null, + completedAt: null, + cancelledAt: null, + version: 1, + createdAt: "2026-08-23T12:00:00.000Z", + updatedAt: "2026-08-23T12:00:00.000Z", + awaitingConfirmation: false, + ...overrides, + }; +} + +test("schedule commands are canonical HTTP thin clients with raw JSON success", async () => { + const requests: CapturedRequest[] = []; + let nextStatus = 200; + let nextBody: unknown = scheduleItem(); + const server = createServer(async (request, response) => { + requests.push({ + method: request.method ?? "", + url: request.url ?? "", + body: await readJsonBody(request), + }); + response.writeHead(nextStatus, { "content-type": "application/json" }); + response.end(JSON.stringify(nextBody)); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const tempDir = await mkdtemp(join(tmpdir(), "echolog-schedule-cli-")); + const configPath = join(tempDir, "config.yaml"); + await writeFile(configPath, [ + "server:", + ` port: ${address.port}`, + " host: localhost", + "", + ].join("\n")); + + async function expectRequest(input: { + args: string[]; + method: string; + url: string; + body?: unknown; + response?: unknown; + }): Promise<void> { + const before = requests.length; + nextStatus = 200; + nextBody = input.response ?? scheduleItem(); + const result = await runCli(configPath, [...input.args, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.deepEqual(JSON.parse(result.stdout), nextBody); + assert.equal(requests.length, before + 1); + assert.deepEqual(requests[before], { + method: input.method, + url: input.url, + body: input.body, + }); + } + + try { + const listResponse = [scheduleItem()]; + await expectRequest({ + args: [ + "schedule", "list", + "--from", "2026-08-24T00:00:00+08:00", + "--to", "2026-08-25T00:00:00+08:00", + "--status", "scheduled,active", + ], + method: "GET", + url: "/api/plugins/schedule/items?from=2026-08-24T00%3A00%3A00%2B08%3A00&to=2026-08-25T00%3A00%3A00%2B08%3A00&status=scheduled%2Cactive", + response: listResponse, + }); + + await expectRequest({ + args: ["schedule", "show", "item with space"], + method: "GET", + url: "/api/plugins/schedule/items/item%20with%20space", + }); + + await expectRequest({ + args: [ + "schedule", "add", "设计评审", + "--start", "2026-08-24T09:00:00+08:00", + "--end", "2026-08-24T10:00:00+08:00", + "--timezone", "Asia/Shanghai", + "--description", "确认 API 契约", + "--priority", "2", + "--no-reminder", + ], + method: "POST", + url: "/api/plugins/schedule/items", + body: { + title: "设计评审", + scheduledStartAt: "2026-08-24T09:00:00+08:00", + timezone: "Asia/Shanghai", + description: "确认 API 契约", + scheduledEndAt: "2026-08-24T10:00:00+08:00", + priority: 2, + nextReminderAt: null, + }, + }); + + await expectRequest({ + args: [ + "schedule", "edit", "schedule-1", + "--expected-version", "3", + "--title", "设计评审(更新)", + "--start", "2026-08-24T10:00:00+08:00", + "--timezone", "Asia/Shanghai", + "--priority", "4", + "--clear-description", + "--clear-end", + "--clear-reminder", + ], + method: "PATCH", + url: "/api/plugins/schedule/items/schedule-1", + body: { + expectedVersion: 3, + title: "设计评审(更新)", + description: null, + scheduledStartAt: "2026-08-24T10:00:00+08:00", + scheduledEndAt: null, + timezone: "Asia/Shanghai", + priority: 4, + nextReminderAt: null, + }, + }); + + await expectRequest({ + args: ["schedule", "confirm", "schedule-1", "--expected-version", "1"], + method: "POST", + url: "/api/plugins/schedule/items/schedule-1/confirm-start", + body: { expectedVersion: 1 }, + }); + + await expectRequest({ + args: [ + "schedule", "snooze", "schedule-1", + "--until", "2026-08-24T09:15:00+08:00", + "--expected-version", "2", + ], + method: "POST", + url: "/api/plugins/schedule/items/schedule-1/snooze", + body: { + expectedVersion: 2, + nextReminderAt: "2026-08-24T09:15:00+08:00", + }, + }); + + await expectRequest({ + args: ["schedule", "done", "schedule-1", "--expected-version", "3"], + method: "POST", + url: "/api/plugins/schedule/items/schedule-1/complete", + body: { expectedVersion: 3 }, + }); + + await expectRequest({ + args: ["schedule", "cancel", "schedule-1", "--expected-version", "4"], + method: "POST", + url: "/api/plugins/schedule/items/schedule-1/cancel", + body: { expectedVersion: 4 }, + }); + } finally { + await new Promise<void>((resolve, reject) => server.close((error) => + error ? reject(error) : resolve() + )); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("schedule human output is readable and 409 JSON errors remain structured on stderr", async () => { + let status = 200; + let body: unknown = [scheduleItem()]; + const server = createServer(async (request, response) => { + await readJsonBody(request); + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const tempDir = await mkdtemp(join(tmpdir(), "echolog-schedule-cli-errors-")); + const configPath = join(tempDir, "config.yaml"); + await writeFile(configPath, [ + "server:", + ` port: ${address.port}`, + " host: localhost", + "", + ].join("\n")); + + try { + const human = await runCli(configPath, ["schedule", "list"]); + assert.equal(human.exitCode, 0, human.stderr); + assert.equal(human.stderr, ""); + assert.match(human.stdout, /设计评审 \[schedule-1\] scheduled/); + assert.match(human.stdout, /Asia\/Shanghai/); + assert.match(human.stdout, /v1/); + + const conflict = { + error: "Schedule item changed concurrently", + currentVersion: 2, + currentStatus: "active", + }; + status = 409; + body = conflict; + const json = await runCli(configPath, [ + "schedule", "confirm", "schedule-1", "--expected-version", "1", "--json", + ]); + assert.equal(json.exitCode, 1); + assert.equal(json.stdout, ""); + assert.deepEqual(JSON.parse(json.stderr), conflict); + } finally { + await new Promise<void>((resolve, reject) => server.close((error) => + error ? reject(error) : resolve() + )); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("schedule rejects invalid local numeric options with non-zero JSON errors", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "echolog-schedule-cli-validation-")); + const configPath = join(tempDir, "config.yaml"); + await writeFile(configPath, [ + "server:", + " port: 1", + " host: localhost", + "", + ].join("\n")); + + try { + const invalidVersion = await runCli(configPath, [ + "schedule", "done", "schedule-1", "--expected-version", "1.5", "--json", + ]); + assert.equal(invalidVersion.exitCode, 1); + assert.equal(invalidVersion.stdout, ""); + assert.deepEqual(JSON.parse(invalidVersion.stderr), { + error: "--expected-version 必须是大于或等于 1 的整数", + }); + + const invalidPriority = await runCli(configPath, [ + "schedule", "add", "设计评审", + "--start", "2026-08-24T09:00:00+08:00", + "--timezone", "Asia/Shanghai", + "--priority", "high", + "--json", + ]); + assert.equal(invalidPriority.exitCode, 1); + assert.equal(invalidPriority.stdout, ""); + assert.deepEqual(JSON.parse(invalidPriority.stderr), { + error: "--priority 必须是整数", + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("schedule help documents commands, concurrency, offset times, timezone, and examples", async () => { + const scheduleHelp = await runCli("/does/not/matter.yaml", ["schedule", "--help"]); + assert.equal(scheduleHelp.exitCode, 0, scheduleHelp.stderr); + for (const command of ["list", "show", "add", "edit", "confirm", "snooze", "done", "cancel"]) { + assert.match(scheduleHelp.stdout, new RegExp(`\\b${command}\\b`)); + } + assert.match(scheduleHelp.stdout, /不会自动开始/); + assert.match(scheduleHelp.stdout, /ISO-8601/); + assert.match(scheduleHelp.stdout, /\+08:00/); + assert.match(scheduleHelp.stdout, /IANA/); + assert.match(scheduleHelp.stdout, /Asia\/Shanghai/); + + const editHelp = await runCli("/does/not/matter.yaml", ["schedule", "edit", "--help"]); + assert.equal(editHelp.exitCode, 0, editHelp.stderr); + assert.match(editHelp.stdout, /--expected-version <n>/); + assert.match(editHelp.stdout, /409/); + assert.match(editHelp.stdout, /--clear-reminder/); + + const snoozeHelp = await runCli("/does/not/matter.yaml", ["schedule", "snooze", "--help"]); + assert.equal(snoozeHelp.exitCode, 0, snoozeHelp.stderr); + assert.match(snoozeHelp.stdout, /--until <ISO>/); + assert.match(snoozeHelp.stdout, /不会改变状态或自动开始/); +}); diff --git a/tests/schedule-web.test.ts b/tests/schedule-web.test.ts new file mode 100644 index 0000000..8ea61be --- /dev/null +++ b/tests/schedule-web.test.ts @@ -0,0 +1,442 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPluginWebHost } from "../web/plugin-host.js"; +import { + activate, + scheduleWebTest, +} from "../plugins/schedule/web/index.js"; + +const NOW = new Date("2026-08-24T00:00:00.000Z"); + +type ItemOverrides = Partial<{ + id: string; + title: string; + description: string | null; + scheduledStartAt: string; + scheduledEndAt: string | null; + timezone: string; + priority: number; + status: string; + nextReminderAt: string | null; + confirmedStartAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + version: number; + awaitingConfirmation: boolean; +}>; + +function item(overrides: ItemOverrides = {}) { + return { + id: "schedule-1", + title: "例会", + description: null, + scheduledStartAt: "2026-08-24T01:00:00.000Z", + scheduledEndAt: null, + timezone: "Asia/Shanghai", + priority: 0, + status: "scheduled", + nextReminderAt: "2026-08-24T01:00:00.000Z", + confirmedStartAt: null, + completedAt: null, + cancelledAt: null, + version: 1, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + awaitingConfirmation: false, + ...overrides, + }; +} + +function escapeText(value: unknown) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function escapeAttribute(value: unknown) { + return escapeText(value).replaceAll("'", "'"); +} + +function renderContext(scheduleItems: ReturnType<typeof item>[]) { + return { + data: { scheduleItems }, + esc: escapeText, + escA: escapeAttribute, + fmtDur: String, + }; +} + +function styleRoot() { + const links: Array<Record<string, any>> = []; + const head = { + appendChild(link: Record<string, any>) { + links.push(link); + }, + }; + const documentRef = { + head, + createElement(tagName: string) { + const link: Record<string, any> = { + tagName, + dataset: {}, + remove() { + const index = links.indexOf(link); + if (index >= 0) links.splice(index, 1); + }, + }; + return link; + }, + }; + return { root: { ownerDocument: documentRef }, links }; +} + +function sectionFor(html: string, dateKey: string) { + const escaped = dateKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return html.match(new RegExp(`<section[^>]+data-date="${escaped}"[^>]*>([\\s\\S]*?)</section>`))?.[1] ?? ""; +} + +test("Schedule month, week, and day ranges group one item source in each item's timezone", () => { + const shanghai = item({ + id: "shanghai", + title: "上海早会", + scheduledStartAt: "2026-08-23T23:30:00.000Z", + timezone: "Asia/Shanghai", + }); + const losAngeles = item({ + id: "los-angeles", + title: "洛杉矶回顾", + scheduledStartAt: "2026-08-23T23:30:00.000Z", + timezone: "America/Los_Angeles", + }); + const spanning = item({ + id: "spanning", + title: "跨日发布", + scheduledStartAt: "2026-08-24T15:30:00.000Z", + scheduledEndAt: "2026-08-25T16:30:00.000Z", + timezone: "Asia/Shanghai", + }); + const items = [shanghai, losAngeles, spanning]; + + assert.deepEqual(scheduleWebTest.rangeForView("month", "2026-08-24"), { + from: "2026-07-27", + to: "2026-09-07", + keys: Array.from({ length: 42 }, (_, index) => scheduleWebTest.addDays("2026-07-27", index)), + }); + assert.deepEqual(scheduleWebTest.rangeForView("week", "2026-08-24").keys, [ + "2026-08-24", "2026-08-25", "2026-08-26", "2026-08-27", + "2026-08-28", "2026-08-29", "2026-08-30", + ]); + assert.deepEqual(scheduleWebTest.rangeForView("day", "2026-08-24").keys, ["2026-08-24"]); + assert.equal(scheduleWebTest.dateKeyInTimezone(shanghai.scheduledStartAt, shanghai.timezone), "2026-08-24"); + assert.equal(scheduleWebTest.dateKeyInTimezone(losAngeles.scheduledStartAt, losAngeles.timezone), "2026-08-23"); + + const month = scheduleWebTest.groupItems(items, "month", "2026-08-24"); + assert.deepEqual(month.groups.get("2026-08-23").map((entry: any) => entry.id), ["los-angeles"]); + assert.deepEqual(month.groups.get("2026-08-24").map((entry: any) => entry.id), ["shanghai", "spanning"]); + assert.deepEqual(month.groups.get("2026-08-25").map((entry: any) => entry.id), ["spanning"]); + assert.deepEqual(month.groups.get("2026-08-26").map((entry: any) => entry.id), ["spanning"]); + + const week = scheduleWebTest.groupItems(items, "week", "2026-08-24"); + assert.equal([...week.groups.values()].flat().some((entry: any) => entry.id === "los-angeles"), false); + const day = scheduleWebTest.groupItems(items, "day", "2026-08-24"); + assert.deepEqual(day.groups.get("2026-08-24").map((entry: any) => entry.id), ["shanghai", "spanning"]); +}); + +test("Schedule loads one canonical range and renders month/week/day placement with derived awaiting state", async () => { + const fixtures = [ + item({ + id: "shanghai", + title: "上海早会", + scheduledStartAt: "2026-08-23T23:30:00.000Z", + timezone: "Asia/Shanghai", + awaitingConfirmation: false, + }), + item({ + id: "los-angeles", + title: "洛杉矶回顾", + scheduledStartAt: "2026-08-23T23:30:00.000Z", + timezone: "America/Los_Angeles", + }), + ]; + const calls: Array<{ path: string; options?: unknown }> = []; + const { root } = styleRoot(); + const contribution = await activate({ + root, + now: () => NOW, + api: async (path: string, options?: unknown) => { + calls.push({ path, options }); + return fixtures; + }, + }); + const data = await contribution.load(); + assert.equal(calls.length, 1); + const request = new URL(calls[0].path, "http://echolog.local"); + assert.equal(request.pathname, "/plugins/schedule/items"); + assert.deepEqual(Object.fromEntries(request.searchParams), { + from: "2026-07-25T00:00:00.000Z", + to: "2026-09-09T00:00:00.000Z", + }); + assert.deepEqual(contribution.faces(), [ + { type: "schedule-overview" }, + { type: "schedule-month" }, + { type: "schedule-week" }, + { type: "schedule-day" }, + ]); + + const context = renderContext(data.scheduleItems); + const overview = contribution.renderFace({ type: "schedule-overview" }, context); + assert.match(overview, /schedule-awaiting/); + assert.match(overview, /待确认/); + assert.equal(overview.includes("data-act=\"schedule-ignore\""), false); + + const month = contribution.renderFace({ type: "schedule-month" }, context); + assert.match(sectionFor(month, "2026-08-24"), /上海早会/); + assert.equal(sectionFor(month, "2026-08-24").includes("洛杉矶回顾"), false); + assert.match(sectionFor(month, "2026-08-23"), /洛杉矶回顾/); + + const week = contribution.renderFace({ type: "schedule-week" }, context); + assert.match(sectionFor(week, "2026-08-24"), /上海早会/); + assert.equal(week.includes("洛杉矶回顾"), false); + + const day = contribution.renderFace({ type: "schedule-day" }, context); + assert.match(day, /上海早会/); + assert.equal(day.includes("洛杉矶回顾"), false); + assert.equal(contribution.renderFace({ type: "not-schedule" }, context), null); +}); + +test("Schedule escapes every dynamic render value and never fabricates notification controls", async () => { + const malicious = item({ + id: 'id"><svg onload=alert(1)>', + title: '<img src=x onerror="alert(2)">', + description: "</textarea><script>alert(3)</script>", + timezone: '"><script>alert(4)</script>', + scheduledStartAt: "2026-08-23T00:00:00.000Z", + }); + const contribution = await activate({ api: async () => [malicious], now: () => NOW }); + const data = await contribution.load(); + const html = contribution.renderFace({ type: "schedule-overview" }, renderContext(data.scheduleItems)); + + assert.equal(html.includes(malicious.id), false); + assert.equal(html.includes(malicious.title), false); + assert.equal(html.includes(malicious.description ?? ""), false); + assert.equal(html.includes(malicious.timezone), false); + assert.match(html, /<img src=x onerror="alert\(2\)">/); + assert.match(html, /<\/textarea><script>alert\(3\)<\/script>/); + assert.equal(html.includes("notification"), false); + assert.equal(html.includes("schedule-ignore"), false); +}); + +test("Schedule create and explicit actions use canonical routes, latest expectedVersion, and no write on ignore", async () => { + const original = item({ + id: "item/with space", + version: 7, + scheduledStartAt: "2026-08-23T00:00:00.000Z", + }); + const actionId = `overview:${encodeURIComponent(original.id)}`; + const calls: Array<{ path: string; options?: { method?: string; body?: string } }> = []; + let returnedVersion = original.version; + const contribution = await activate({ + now: () => NOW, + api: async (path: string, options?: { method?: string; body?: string }) => { + calls.push({ path, options }); + if (!options) return [original]; + if (path === "/plugins/schedule/items") return item({ id: "created" }); + returnedVersion++; + return { ...original, version: returnedVersion }; + }, + }); + await contribution.load(); + const elements: Record<string, { value?: string; textContent?: string }> = { + scheduleTitle: { value: "写发布说明" }, + scheduleDescription: { value: "说明" }, + scheduleStart: { value: "2026-08-25T09:00:00+08:00" }, + scheduleEnd: { value: "2026-08-25T10:30:00+08:00" }, + scheduleTimezone: { value: "Asia/Shanghai" }, + schedulePriority: { value: "12" }, + scheduleCreateError: { textContent: "" }, + scheduleActionError: { textContent: "" }, + [`scheduleSnooze:${actionId}`]: { value: "15" }, + }; + const $ = (id: string) => elements[id] ?? null; + + const beforeIgnore = calls.length; + assert.deepEqual(await contribution.handleAction("schedule-ignore", { + id: original.id, $, confirm: () => true, + }), { handled: true, refresh: false }); + assert.equal(calls.length, beforeIgnore); + + await contribution.handleAction("schedule-create", { id: "", $, confirm: () => true }); + const createCall = calls.at(-1); + assert.equal(createCall?.path, "/plugins/schedule/items"); + assert.equal(createCall?.options?.method, "POST"); + assert.deepEqual(JSON.parse(createCall?.options?.body ?? ""), { + title: "写发布说明", + description: "说明", + scheduledStartAt: "2026-08-25T09:00:00+08:00", + scheduledEndAt: "2026-08-25T10:30:00+08:00", + timezone: "Asia/Shanghai", + priority: 12, + }); + + await contribution.handleAction("schedule-confirm-start", { + id: actionId, $, confirm: () => true, + }); + await contribution.handleAction("schedule-snooze", { + id: actionId, $, confirm: () => true, + }); + await contribution.handleAction("schedule-complete", { + id: actionId, $, confirm: () => true, + }); + const beforeRejectedCancel = calls.length; + await contribution.handleAction("schedule-cancel", { + id: actionId, $, confirm: () => false, + }); + assert.equal(calls.length, beforeRejectedCancel); + await contribution.handleAction("schedule-cancel", { + id: actionId, $, confirm: () => true, + }); + + const transitionCalls = calls.filter((call) => call.path.includes("item%2Fwith%20space")); + assert.deepEqual(transitionCalls.map((call) => call.path), [ + "/plugins/schedule/items/item%2Fwith%20space/confirm-start", + "/plugins/schedule/items/item%2Fwith%20space/snooze", + "/plugins/schedule/items/item%2Fwith%20space/complete", + "/plugins/schedule/items/item%2Fwith%20space/cancel", + ]); + assert.deepEqual(transitionCalls.map((call) => JSON.parse(call.options?.body ?? "")), [ + { expectedVersion: 7 }, + { expectedVersion: 8, nextReminderAt: "2026-08-24T00:15:00.000Z" }, + { expectedVersion: 9 }, + { expectedVersion: 10 }, + ]); +}); + +test("Schedule scopes duplicate item controls by face and day snooze reads only the day input", async () => { + const adversarialId = 'item/with:%"><svg data-x="1">'; + const scheduled = item({ + id: adversarialId, + title: "跨页面日程", + scheduledStartAt: "2026-08-23T23:30:00.000Z", + timezone: "Asia/Shanghai", + version: 21, + }); + const calls: Array<{ path: string; options?: { body?: string } }> = []; + const contribution = await activate({ + now: () => NOW, + api: async (path: string, options?: { body?: string }) => { + calls.push({ path, options }); + return options ? { ...scheduled, version: 22 } : [scheduled]; + }, + }); + const data = await contribution.load(); + const context = renderContext(data.scheduleItems); + const overview = contribution.renderFace({ type: "schedule-overview" }, context); + const day = contribution.renderFace({ type: "schedule-day" }, context); + const controlIds = [...`${overview}${day}`.matchAll(/\bid="(scheduleSnooze:[^"]+)"/g)] + .map((match) => match[1]); + assert.equal(controlIds.length, 2); + assert.equal(new Set(controlIds).size, 2); + assert.match(controlIds[0], /^scheduleSnooze:overview:/); + assert.match(controlIds[1], /^scheduleSnooze:day:/); + assert.equal(overview.includes(adversarialId), false); + assert.equal(day.includes(adversarialId), false); + + const dayTarget = day.match(/data-act="schedule-snooze" data-id="([^"]+)"/)?.[1]; + assert.ok(dayTarget); + const elements: Record<string, { value?: string; textContent?: string }> = { + [controlIds[0]]: { value: "3" }, + [controlIds[1]]: { value: "47" }, + scheduleActionError: { textContent: "" }, + }; + await contribution.handleAction("schedule-snooze", { + id: dayTarget, + $: (id: string) => elements[id] ?? null, + confirm: () => true, + }); + + const snooze = calls.at(-1); + assert.equal( + snooze?.path, + `/plugins/schedule/items/${encodeURIComponent(adversarialId)}/snooze` + ); + assert.deepEqual(JSON.parse(snooze?.options?.body ?? ""), { + expectedVersion: 21, + nextReminderAt: "2026-08-24T00:47:00.000Z", + }); +}); + +test("Schedule validates create and snooze locally without issuing a write", async () => { + const calls: string[] = []; + const contribution = await activate({ + now: () => NOW, + api: async (path: string, options?: unknown) => { + calls.push(path); + return options ? item() : [item()]; + }, + }); + await contribution.load(); + const elements: Record<string, { value?: string; textContent?: string }> = { + scheduleTitle: { value: "无偏移时刻" }, + scheduleDescription: { value: "" }, + scheduleStart: { value: "2026-08-25T09:00" }, + scheduleEnd: { value: "" }, + scheduleTimezone: { value: "Asia/Shanghai" }, + schedulePriority: { value: "0" }, + scheduleCreateError: { textContent: "" }, + scheduleActionError: { textContent: "" }, + "scheduleSnooze:schedule-1": { value: "0" }, + }; + const $ = (id: string) => elements[id] ?? null; + const before = calls.length; + await contribution.handleAction("schedule-create", { id: "", $, confirm: () => true }); + await contribution.handleAction("schedule-snooze", { id: "schedule-1", $, confirm: () => true }); + assert.equal(calls.length, before); + assert.match(elements.scheduleCreateError.textContent ?? "", /Z|偏移/); + assert.match(elements.scheduleActionError.textContent ?? "", /1 至 10080/); +}); + +test("Schedule owns exactly one stylesheet and removes it idempotently on unmount", async () => { + const { root, links } = styleRoot(); + const contribution = await activate({ root, api: async () => [], now: () => NOW }); + assert.equal(links.length, 1); + assert.equal(links[0].rel, "stylesheet"); + assert.equal(links[0].href, "/plugins/schedule/styles.css"); + assert.equal(links[0].dataset.echologPluginStyle, "schedule"); + await contribution.unmount(); + assert.equal(links.length, 0); + await contribution.unmount(); + assert.equal(links.length, 0); +}); + +test("plugin Web host activates Schedule only while it is enabled and ready", async () => { + const modulePath = new URL("../plugins/schedule/web/index.js", import.meta.url).href; + const { root, links } = styleRoot(); + let state: "disabled" | "degraded" | "ready" = "disabled"; + const host = createPluginWebHost(async (path: string) => { + assert.equal(path, "/plugins"); + return { + plugins: [{ id: "schedule", enabled: state !== "disabled", state, webEntry: modulePath }], + }; + }); + const hostApi = { root, api: async () => [], now: () => NOW }; + + await host.refresh(hostApi); + assert.deepEqual(host.faces(), []); + assert.equal(links.length, 0); + state = "degraded"; + await host.refresh(hostApi); + assert.deepEqual(host.faces(), []); + assert.equal(links.length, 0); + state = "ready"; + await host.refresh(hostApi); + assert.equal(host.faces().length, 4); + assert.equal(links.length, 1); + await host.refresh(hostApi); + assert.equal(links.length, 1); + state = "degraded"; + await host.refresh(hostApi); + assert.deepEqual(host.faces(), []); + assert.equal(links.length, 0); +}); diff --git a/tests/schedule.integration.ts b/tests/schedule.integration.ts new file mode 100644 index 0000000..0f73fec --- /dev/null +++ b/tests/schedule.integration.ts @@ -0,0 +1,392 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import Fastify from "fastify"; +import postgres from "postgres"; +import { schedulePlugin } from "../plugins/schedule/src/index.js"; +import { pollDueReminders } from "../plugins/schedule/src/reminders.js"; +import { + ScheduleConflictError, + ScheduleStore, +} from "../plugins/schedule/src/store.js"; +import { PluginHost } from "../src/core/plugins/host.js"; +import { createPluginMigrationRunner } from "../src/core/plugins/migrations.js"; +import { pluginRoutes } from "../src/server/routes/plugins.js"; + +const testDatabaseUrl = process.env.ECHOLOG_TEST_DATABASE_URL; + +function testSchemaName(): string { + return `el_test_schedule_${process.pid}_${randomUUID().replaceAll("-", "").slice(0, 12)}`; +} + +function quoteTestSchema(schema: string): string { + if (!/^el_test_schedule_\d+_[a-f0-9]{12}$/.test(schema)) { + throw new Error("refusing to use a non-test schema"); + } + return `"${schema}"`; +} + +function databaseUrlForSchema(databaseUrl: string, schema: string): string { + const url = new URL(databaseUrl); + url.searchParams.set("options", `-c search_path=${schema}`); + return url.toString(); +} + +const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +test("schedule integration requires an explicit test database URL", () => { + assert.ok( + testDatabaseUrl, + "set ECHOLOG_TEST_DATABASE_URL to run PostgreSQL integration tests" + ); +}); + +test( + "Schedule persists CAS transitions, range routes, and at-most-once reminder ledgers", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => { + if (!testDatabaseUrl) return; + const schema = testSchemaName(); + const quotedSchema = quoteTestSchema(schema); + const scopedDatabaseUrl = databaseUrlForSchema(testDatabaseUrl, schema); + const admin = postgres(testDatabaseUrl, { max: 1 }); + let schemaCreated = false; + let firstStore: ScheduleStore | null = null; + let secondStore: ScheduleStore | null = null; + let restartedStore: ScheduleStore | null = null; + let drainRestartStore: ScheduleStore | null = null; + let host: PluginHost | null = null; + let app: ReturnType<typeof Fastify> | null = null; + + try { + await admin.unsafe(`CREATE SCHEMA ${quotedSchema}`); + schemaCreated = true; + const migrationRunner = createPluginMigrationRunner(scopedDatabaseUrl); + const migrations = schedulePlugin.migrations ?? []; + await migrationRunner("schedule", migrations); + await migrationRunner("schedule", migrations); + + firstStore = new ScheduleStore(scopedDatabaseUrl); + secondStore = new ScheduleStore(scopedDatabaseUrl); + const originalDefaultStart = new Date("2026-08-27T10:00:00Z"); + const movedDefaultStart = new Date("2026-08-27T11:00:00Z"); + const defaultReminder = await firstStore.create({ + title: "Default reminder follows start", + description: null, + scheduledStartAt: originalDefaultStart, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: originalDefaultStart, + }); + const movedDefault = await firstStore.edit(defaultReminder.id, 1, { + scheduledStartAt: movedDefaultStart, + }); + assert.equal(movedDefault.scheduledStartAt, movedDefaultStart.toISOString()); + assert.equal(movedDefault.nextReminderAt, movedDefaultStart.toISOString()); + + const customReminderAt = new Date("2026-08-27T09:30:00Z"); + const customReminder = await firstStore.create({ + title: "Custom reminder stays fixed", + description: null, + scheduledStartAt: originalDefaultStart, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: customReminderAt, + }); + const movedCustom = await firstStore.edit(customReminder.id, 1, { + scheduledStartAt: movedDefaultStart, + }); + assert.equal(movedCustom.scheduledStartAt, movedDefaultStart.toISOString()); + assert.equal(movedCustom.nextReminderAt, customReminderAt.toISOString()); + + const explicitReminder = await firstStore.create({ + title: "Explicit edit reminder wins", + description: null, + scheduledStartAt: originalDefaultStart, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: originalDefaultStart, + }); + const explicitEditedReminderAt = new Date("2026-08-27T10:45:00Z"); + const movedExplicit = await firstStore.edit(explicitReminder.id, 1, { + scheduledStartAt: movedDefaultStart, + nextReminderAt: explicitEditedReminderAt, + }); + assert.equal(movedExplicit.scheduledStartAt, movedDefaultStart.toISOString()); + assert.equal( + movedExplicit.nextReminderAt, + explicitEditedReminderAt.toISOString() + ); + + const plannedStart = new Date("2026-08-24T02:00:00Z"); + const concurrent = await firstStore.create({ + title: "Concurrent confirmation", + description: null, + scheduledStartAt: plannedStart, + scheduledEndAt: new Date("2026-08-24T03:00:00Z"), + timezone: "Asia/Shanghai", + priority: 1, + nextReminderAt: plannedStart, + }, new Date("2026-08-24T00:00:00Z")); + const confirmationBefore = new Date(); + const [left, right] = await Promise.allSettled([ + firstStore.confirmStart(concurrent.id, 1), + secondStore.confirmStart(concurrent.id, 1), + ]); + const fulfilled = [left, right].filter( + (result): result is PromiseFulfilledResult<Awaited<ReturnType<ScheduleStore["confirmStart"]>>> => + result.status === "fulfilled" + ); + const rejected = [left, right].filter( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + assert.equal(fulfilled.length, 1); + assert.equal(rejected.length, 1); + assert.ok(rejected[0]!.reason instanceof ScheduleConflictError); + assert.deepEqual(rejected[0]!.reason.metadata, { + currentVersion: 2, + currentStatus: "active", + }); + const winner = fulfilled[0]!.value; + assert.equal(winner.status, "active"); + assert.equal(winner.version, 2); + assert.equal(winner.nextReminderAt, null); + assert.notEqual(winner.confirmedStartAt, concurrent.scheduledStartAt); + assert.ok(new Date(winner.confirmedStartAt!).getTime() >= confirmationBefore.getTime()); + + const dueAt = new Date("2026-08-24T01:00:00Z"); + const reminderItem = await firstStore.create({ + title: "Reminder only", + description: "Do not start automatically", + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }, new Date("2026-08-24T00:00:00Z")); + let sends = 0; + const send = async () => { + sends++; + return { + channels: { + mac: { status: "sent" as const }, + ntfy: { status: "disabled" as const }, + }, + }; + }; + const pollAt = new Date("2026-08-24T02:00:00Z"); + await pollDueReminders(firstStore, send, new AbortController().signal, { + now: pollAt, + }); + await pollDueReminders(firstStore, send, new AbortController().signal, { + now: pollAt, + }); + restartedStore = new ScheduleStore(scopedDatabaseUrl); + await pollDueReminders(restartedStore, send, new AbortController().signal, { + now: pollAt, + }); + assert.equal(sends, 1); + assert.equal((await restartedStore.listReminders({ + itemId: reminderItem.id, + limit: 10, + })).length, 1); + const stillScheduled = await restartedStore.get(reminderItem.id, pollAt); + assert.equal(stillScheduled?.status, "scheduled"); + assert.equal(stillScheduled?.version, 1); + assert.equal(stillScheduled?.confirmedStartAt, null); + + const snoozedAt = new Date("2026-08-24T01:30:00Z"); + await restartedStore.snooze(reminderItem.id, 1, snoozedAt, pollAt); + await pollDueReminders(restartedStore, send, new AbortController().signal, { + now: pollAt, + }); + assert.equal(sends, 2); + assert.equal((await restartedStore.listReminders({ + itemId: reminderItem.id, + limit: 10, + })).length, 2); + + const drainItemIds = new Set((await Promise.all( + Array.from({ length: 105 }, (_, index) => firstStore!.create({ + title: `Drain reminder ${String(index + 1).padStart(3, "0")}`, + description: null, + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }, new Date("2026-08-24T00:00:00Z"))) + )).map(({ id }) => id)); + let drainSends = 0; + const drainSend = async () => { + drainSends++; + return { + channels: { + mac: { status: "sent" as const }, + ntfy: { status: "disabled" as const }, + }, + }; + }; + const firstDrain = await pollDueReminders( + firstStore, + drainSend, + new AbortController().signal, + { now: pollAt, limit: 100 } + ); + assert.deepEqual({ + due: firstDrain.due, + claimed: firstDrain.claimed, + sent: firstDrain.sent, + }, { due: 100, claimed: 100, sent: 100 }); + + drainRestartStore = new ScheduleStore(scopedDatabaseUrl); + const afterDrainRestart = await pollDueReminders( + drainRestartStore, + drainSend, + new AbortController().signal, + { now: pollAt, limit: 100 } + ); + assert.deepEqual({ + due: afterDrainRestart.due, + claimed: afterDrainRestart.claimed, + sent: afterDrainRestart.sent, + }, { due: 5, claimed: 5, sent: 5 }); + const drainedAgain = await pollDueReminders( + drainRestartStore, + drainSend, + new AbortController().signal, + { now: pollAt, limit: 100 } + ); + assert.equal(drainedAgain.due, 0); + assert.equal(drainSends, 105); + const drainLedgers = (await drainRestartStore.listReminders({ limit: 500 })) + .filter(({ itemId }) => drainItemIds.has(itemId)); + assert.equal(drainLedgers.length, 105); + assert.equal(new Set(drainLedgers.map(({ dedupeKey }) => dedupeKey)).size, 105); + + const failedItem = await firstStore.create({ + title: "Failed notification remains scheduled", + description: null, + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }); + await pollDueReminders(firstStore, async () => ({ + channels: { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "offline" }, + }, + }), new AbortController().signal, { now: pollAt }); + const failedLedger = await firstStore.listReminders({ + itemId: failedItem.id, + limit: 10, + }); + assert.equal(failedLedger[0]?.status, "failed"); + assert.equal((await firstStore.get(failedItem.id))?.status, "scheduled"); + assert.equal( + (await firstStore.dueReminders(pollAt, 500)) + .some(({ item }) => item.id === failedItem.id), + false, + "failed ledgers must not re-enter a later due batch" + ); + + const crashWindowItem = await firstStore.create({ + title: "Claimed before daemon crash", + description: null, + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }); + assert.ok(await firstStore.claimReminder(crashWindowItem.id, dueAt, pollAt)); + assert.equal( + (await drainRestartStore.dueReminders(pollAt, 500)) + .some(({ item }) => item.id === crashWindowItem.id), + false, + "an unfinished claimed ledger must remain at-most-once after restart" + ); + + host = new PluginHost({ + definitions: [schedulePlugin], + logger, + migrationRunner, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + services: { + "database.url": scopedDatabaseUrl, + "notifications.send": send, + }, + }); + await host.initialize(); + assert.equal(host.list()[0]?.state, "ready"); + app = Fastify({ logger: false }); + await pluginRoutes(app, host); + + const createResponse = await app.inject({ + method: "POST", + url: "/api/plugins/schedule/items", + payload: { + title: "HTTP race", + scheduledStartAt: "2026-08-25T10:00:00+08:00", + scheduledEndAt: "2026-08-25T11:00:00+08:00", + timezone: "Asia/Shanghai", + }, + }); + assert.equal(createResponse.statusCode, 201); + const httpItem = createResponse.json(); + assert.equal(httpItem.version, 1); + assert.equal(httpItem.status, "scheduled"); + + const [firstConfirm, secondConfirm] = await Promise.all([ + app.inject({ + method: "POST", + url: `/api/plugins/schedule/items/${httpItem.id}/confirm-start`, + payload: { expectedVersion: 1 }, + }), + app.inject({ + method: "POST", + url: `/api/plugins/schedule/items/${httpItem.id}/confirm-start`, + payload: { expectedVersion: 1 }, + }), + ]); + assert.deepEqual( + [firstConfirm.statusCode, secondConfirm.statusCode].sort(), + [200, 409] + ); + const conflict = [firstConfirm, secondConfirm].find( + (response) => response.statusCode === 409 + )!.json(); + assert.deepEqual({ + currentVersion: conflict.currentVersion, + currentStatus: conflict.currentStatus, + }, { currentVersion: 2, currentStatus: "active" }); + + const rangeResponse = await app.inject({ + method: "GET", + url: "/api/plugins/schedule/items?from=2026-08-25T00%3A00%3A00Z&to=2026-08-26T00%3A00%3A00Z&status=active", + }); + assert.equal(rangeResponse.statusCode, 200, rangeResponse.body); + assert.equal(Array.isArray(rangeResponse.json()), true); + assert.equal(rangeResponse.json().some(({ id }: { id: string }) => id === httpItem.id), true); + } finally { + await app?.close(); + await host?.stop(); + await drainRestartStore?.close(); + await restartedStore?.close(); + await secondStore?.close(); + await firstStore?.close(); + if (schemaCreated) await admin.unsafe(`DROP SCHEMA ${quotedSchema} CASCADE`); + await admin.end(); + } + } +); diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts new file mode 100644 index 0000000..76c0a92 --- /dev/null +++ b/tests/schedule.test.ts @@ -0,0 +1,512 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import type { + PluginContext, + PluginDefinition, + PluginJob, + PluginManifest, +} from "@echolog/plugin-sdk"; +import { + SCHEDULE_REMINDER_JOB_TIMEOUT_MS, + schedulePlugin, +} from "../plugins/schedule/src/index.js"; +import { pollDueReminders } from "../plugins/schedule/src/reminders.js"; +import { createScheduleRoutes } from "../plugins/schedule/src/routes.js"; +import { + ScheduleConflictError, + reminderDedupeKey, + scheduleItemFromRow, + type DueReminder, +} from "../plugins/schedule/src/store.js"; +import type { + NotificationSendResult, + ReminderDelivery, + ScheduleItem, +} from "../plugins/schedule/src/types.js"; +import { + validateCreateScheduleItem, + validateEditScheduleItem, + validateListQuery, + validateSnoozeBody, +} from "../plugins/schedule/src/validation.js"; +import { PluginHost } from "../src/core/plugins/host.js"; + +const signal = new AbortController().signal; + +function item(overrides: Partial<ScheduleItem> = {}): ScheduleItem { + return { + id: "schedule_001", + title: "Plan release", + description: null, + scheduledStartAt: "2026-08-24T02:00:00.000Z", + scheduledEndAt: "2026-08-24T03:00:00.000Z", + timezone: "Asia/Shanghai", + priority: 0, + status: "scheduled", + nextReminderAt: "2026-08-24T02:00:00.000Z", + confirmedStartAt: null, + completedAt: null, + cancelledAt: null, + version: 1, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:00.000Z", + awaitingConfirmation: true, + ...overrides, + }; +} + +function request(overrides: Partial<{ + params: Record<string, string>; + query: unknown; + body: unknown; +}> = {}) { + return { + params: {}, + query: {}, + body: undefined, + headers: {}, + ...overrides, + }; +} + +function manifest(id: string): PluginManifest { + return { + manifestVersion: 1, + id, + version: "1.0.0", + apiVersion: "1", + displayName: id, + description: `${id} test plugin`, + entries: {}, + capabilities: [], + permissions: [], + requires: { coreApi: "^1.0.0" }, + }; +} + +const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +test("Schedule manifest, config, migrations, and imports preserve plugin boundaries", () => { + assert.equal(schedulePlugin.manifest.id, "schedule"); + assert.equal(schedulePlugin.defaultEnabled, true); + assert.deepEqual(schedulePlugin.manifest.permissions, [ + "database:plugin", + "notifications:send", + ]); + assert.deepEqual(schedulePlugin.defaultConfig, { reminder_poll_seconds: 30 }); + assert.deepEqual(schedulePlugin.validateConfig?.({ reminder_poll_seconds: 1 }), []); + assert.deepEqual(schedulePlugin.validateConfig?.({ reminder_poll_seconds: 3_600 }), []); + assert.deepEqual(schedulePlugin.validateConfig?.({ reminder_poll_seconds: 0 }), [ + "reminder_poll_seconds must be an integer from 1 to 3600", + ]); + assert.deepEqual(schedulePlugin.validateConfig?.({ reminder_poll_seconds: "30" }), [ + "reminder_poll_seconds must be an integer from 1 to 3600", + ]); + + assert.deepEqual(schedulePlugin.migrations?.map(({ name }) => name), [ + "001_schedule_items_and_reminder_deliveries", + ]); + const migration = schedulePlugin.migrations?.[0]?.sql ?? ""; + assert.match(migration, /schedule_items/); + assert.match(migration, /schedule_reminder_deliveries/); + assert.match(migration, /TIMESTAMPTZ/g); + assert.match(migration, /CREATE UNIQUE INDEX[\s\S]*dedupe_key/); + assert.match(migration, /version >= 1/); + assert.match(migration, /status IN \('scheduled', 'active', 'done', 'cancelled'\)/); + assert.doesNotMatch(migration, /calendar_events|records|inspiration/i); + + const sources = ["index.ts", "reminders.ts", "routes.ts", "store.ts", "types.ts"] + .map((name) => readFileSync( + new URL(`../plugins/schedule/src/${name}`, import.meta.url), + "utf8" + )) + .join("\n"); + assert.match(sources, /service<NotificationSend>\("notifications\.send"\)/); + assert.doesNotMatch(sources, /core\/|notifier|inspiration|recordService/i); +}); + +test("Schedule boundary validation rejects local datetimes and unknown fields", () => { + const valid = validateCreateScheduleItem({ + title: " Release ", + scheduledStartAt: "2026-08-24T10:00:00+08:00", + scheduledEndAt: "2026-08-24T11:00:00+08:00", + timezone: "Asia/Shanghai", + }); + assert.equal(valid.ok, true); + if (valid.ok) { + assert.equal(valid.value.title, "Release"); + assert.equal(valid.value.nextReminderAt.toISOString(), "2026-08-24T02:00:00.000Z"); + assert.equal(valid.value.priority, 0); + } + + for (const invalid of [ + { title: "x", scheduledStartAt: "2026-08-24T10:00:00", timezone: "UTC" }, + { title: "x", scheduledStartAt: "2026-08-24T10:00:00Z", timezone: "Mars/Base" }, + { + title: "x", + scheduledStartAt: "2026-08-24T10:00:00Z", + scheduledEndAt: "2026-08-24T09:00:00Z", + timezone: "UTC", + }, + { + title: "x", + scheduledStartAt: "2026-08-24T10:00:00Z", + timezone: "UTC", + status: "active", + }, + ]) { + assert.equal(validateCreateScheduleItem(invalid).ok, false); + } + + assert.deepEqual(validateEditScheduleItem({ expectedVersion: 1 }), { + ok: false, + error: "at least one editable field is required", + }); + assert.equal(validateEditScheduleItem({ + expectedVersion: 1, + nextReminderAt: null, + }).ok, true); + assert.equal(validateSnoozeBody({ + expectedVersion: 1, + nextReminderAt: null, + }).ok, false); + assert.equal(validateListQuery({ + from: "2026-08-25T00:00:00Z", + to: "2026-08-24T00:00:00Z", + }).ok, false); + assert.equal(validateListQuery({ status: "scheduled,active" }).ok, true); + assert.equal(validateListQuery({ status: "scheduled,scheduled" }).ok, false); +}); + +test("awaitingConfirmation is derived and never persisted", () => { + const base = { + id: "schedule_001", + title: "Release", + description: null, + scheduledStartAt: new Date("2026-08-24T02:00:00Z"), + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + status: "scheduled" as const, + nextReminderAt: null, + confirmedStartAt: null, + completedAt: null, + cancelledAt: null, + version: 1, + createdAt: new Date("2026-08-24T00:00:00Z"), + updatedAt: new Date("2026-08-24T00:00:00Z"), + }; + assert.equal( + scheduleItemFromRow(base, new Date("2026-08-24T01:59:59Z")).awaitingConfirmation, + false + ); + assert.equal( + scheduleItemFromRow(base, new Date("2026-08-24T02:00:00Z")).awaitingConfirmation, + true + ); + assert.equal( + scheduleItemFromRow( + { ...base, status: "active", confirmedStartAt: new Date() }, + new Date("2026-08-24T03:00:00Z") + ).awaitingConfirmation, + false + ); +}); + +test("canonical routes preserve raw item arrays and structured conflicts", async () => { + const current = item(); + const calls: Array<{ method: string; args: unknown[] }> = []; + const fakeStore = { + async list(filter: unknown) { + calls.push({ method: "list", args: [filter] }); + return [current]; + }, + async create(input: unknown) { + calls.push({ method: "create", args: [input] }); + return current; + }, + async get(id: string) { + return id === current.id ? current : null; + }, + async edit() { + return current; + }, + async confirmStart(id: string, expectedVersion: number) { + throw new ScheduleConflictError(id, expectedVersion, { + currentVersion: 2, + currentStatus: "active", + }); + }, + async snooze() { return current; }, + async complete() { return current; }, + async cancel() { return current; }, + async listReminders() { return []; }, + }; + const routes = createScheduleRoutes(() => fakeStore as never); + assert.deepEqual(routes.map(({ method, path }) => `${method} ${path}`), [ + "GET /api/plugins/schedule/items", + "POST /api/plugins/schedule/items", + "GET /api/plugins/schedule/items/:id", + "PATCH /api/plugins/schedule/items/:id", + "POST /api/plugins/schedule/items/:id/confirm-start", + "POST /api/plugins/schedule/items/:id/snooze", + "POST /api/plugins/schedule/items/:id/complete", + "POST /api/plugins/schedule/items/:id/cancel", + "GET /api/plugins/schedule/reminders", + ]); + assert.equal(routes.some(({ compatibilityAlias }) => compatibilityAlias), false); + + const list = routes[0]!; + assert.deepEqual(await list.handler(request({ + query: { + from: "2026-08-24T00:00:00Z", + to: "2026-08-25T00:00:00Z", + status: "scheduled,active", + }, + }), signal), [current]); + + const create = routes[1]!; + const created = await create.handler(request({ body: { + title: "Release", + scheduledStartAt: "2026-08-24T10:00:00+08:00", + timezone: "Asia/Shanghai", + } }), signal); + assert.equal((created as { statusCode: number }).statusCode, 201); + + const confirm = routes[4]!; + assert.deepEqual(await confirm.handler(request({ + params: { id: current.id }, + body: { expectedVersion: 1 }, + }), signal), { + statusCode: 409, + body: { + error: `Schedule item ${current.id} has changed or cannot perform this action`, + currentVersion: 2, + currentStatus: "active", + }, + }); + assert.deepEqual(await confirm.handler(request({ + params: { id: current.id }, + body: { expectedVersion: 1, automatic: true }, + }), signal), { + statusCode: 400, + body: { error: "unknown body field: automatic" }, + }); +}); + +interface ReminderState { + due: DueReminder[]; + deliveries: Map<string, ReminderDelivery>; +} + +class MemoryReminderStore { + constructor(private readonly state: ReminderState) {} + + async dueReminders(): Promise<DueReminder[]> { + return this.state.due; + } + + async claimReminder(itemId: string, reminderAt: Date): Promise<ReminderDelivery | null> { + const dedupeKey = reminderDedupeKey(itemId, reminderAt); + if (this.state.deliveries.has(dedupeKey)) return null; + const delivery: ReminderDelivery = { + id: `delivery_${this.state.deliveries.size + 1}`, + dedupeKey, + itemId, + reminderAt: reminderAt.toISOString(), + attemptedAt: new Date().toISOString(), + completedAt: null, + status: "claimed", + channelResults: null, + failure: null, + }; + this.state.deliveries.set(dedupeKey, delivery); + return delivery; + } + + async finishReminder( + id: string, + input: { + status: "sent" | "failed"; + channelResults: NotificationSendResult["channels"] | null; + failure: string | null; + } + ): Promise<ReminderDelivery> { + const entry = [...this.state.deliveries.values()].find((value) => value.id === id); + if (!entry || entry.status !== "claimed") throw new Error("not claimable"); + Object.assign(entry, input, { completedAt: new Date().toISOString() }); + return entry; + } +} + +function reminderState(): ReminderState { + const scheduled = item(); + return { + due: [{ item: scheduled, reminderAt: new Date(scheduled.nextReminderAt!) }], + deliveries: new Map(), + }; +} + +test("reminder polling is at-most-once across repeat polls and store restarts", async () => { + const state = reminderState(); + let sends = 0; + const send = async () => { + sends++; + return { + channels: { + mac: { status: "sent" as const }, + ntfy: { status: "disabled" as const }, + }, + }; + }; + const first = await pollDueReminders( + new MemoryReminderStore(state), + send, + signal + ); + assert.deepEqual(first, { + due: 1, + claimed: 1, + sent: 1, + failed: 0, + deduplicated: 0, + }); + + const afterRestart = await pollDueReminders( + new MemoryReminderStore(state), + send, + signal + ); + assert.equal(sends, 1); + assert.equal(afterRestart.deduplicated, 1); + assert.equal(state.due[0]!.item.status, "scheduled"); + assert.equal(state.due[0]!.item.version, 1); + + state.due[0] = { + item: { ...state.due[0]!.item, nextReminderAt: "2026-08-24T02:30:00.000Z", version: 2 }, + reminderAt: new Date("2026-08-24T02:30:00.000Z"), + }; + await pollDueReminders(new MemoryReminderStore(state), send, signal); + assert.equal(sends, 2, "explicit snooze creates a new reminder instant/dedupe key"); + assert.equal(state.deliveries.size, 2); +}); + +test("reminder polling records disabled, failed, thrown, and aborted deliveries", async () => { + const disabled = reminderState(); + await pollDueReminders(new MemoryReminderStore(disabled), async () => ({ + channels: { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "offline" }, + }, + }), signal); + const disabledDelivery = [...disabled.deliveries.values()][0]!; + assert.equal(disabledDelivery.status, "failed"); + assert.match(disabledDelivery.failure!, /mac: disabled/); + assert.match(disabledDelivery.failure!, /ntfy: offline/); + + const thrown = reminderState(); + await pollDueReminders(new MemoryReminderStore(thrown), async () => { + throw new Error("notification provider unavailable"); + }, signal); + assert.equal([...thrown.deliveries.values()][0]!.status, "failed"); + assert.equal( + [...thrown.deliveries.values()][0]!.failure, + "notification provider unavailable" + ); + + const aborted = reminderState(); + const controller = new AbortController(); + await assert.rejects( + pollDueReminders(new MemoryReminderStore(aborted), async (_request, sendSignal) => { + controller.abort(); + sendSignal?.throwIfAborted(); + throw new Error("unreachable"); + }, controller.signal), + (error) => error instanceof Error && error.name === "AbortError" + ); + assert.equal([...aborted.deliveries.values()][0]!.status, "failed"); + + const preAborted = reminderState(); + const before = new AbortController(); + before.abort(); + await assert.rejects( + pollDueReminders(new MemoryReminderStore(preAborted), async () => { + throw new Error("must not send"); + }, before.signal), + (error) => error instanceof Error && error.name === "AbortError" + ); + assert.equal(preAborted.deliveries.size, 0); +}); + +test("Schedule registers one bounded Host job and cleans lifecycle state", async () => { + let job: PluginJob | null = null; + const context: PluginContext = { + pluginId: "schedule", + config: { reminder_poll_seconds: 7 }, + logger, + registerRoute() {}, + registerJob(value) { job = value; }, + registerReportSection() {}, + async exec() { throw new Error("not used"); }, + service<T>(name: string): T { + if (name === "database.url") return "postgres://localhost/unused" as T; + if (name === "notifications.send") { + return (async () => ({ + channels: { + mac: { status: "disabled" }, + ntfy: { status: "disabled" }, + }, + })) as T; + } + throw new Error(`unexpected service ${name}`); + }, + }; + await schedulePlugin.register?.(context); + assert.equal(job?.id, "reminder-poll"); + assert.equal(job?.intervalMs, 7_000); + assert.equal(job?.timeoutMs, SCHEDULE_REMINDER_JOB_TIMEOUT_MS); + assert.ok(SCHEDULE_REMINDER_JOB_TIMEOUT_MS > job!.intervalMs); + await schedulePlugin.stop?.(context, signal); + await schedulePlugin.stop?.(context, signal); +}); + +test("disabled and missing-service Schedule states remain isolated", async () => { + let migrations = 0; + const disabled = new PluginHost({ + definitions: [schedulePlugin], + configuration: { schedule: { enabled: false } }, + logger, + migrationRunner: async () => { migrations++; }, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + }); + await disabled.initialize(); + assert.equal(disabled.list()[0]?.state, "disabled"); + assert.equal(migrations, 0); + await disabled.stop(); + + const healthy: PluginDefinition = { + manifest: manifest("healthy-after-schedule"), + defaultEnabled: true, + }; + const degraded = new PluginHost({ + definitions: [schedulePlugin, healthy], + logger, + migrationRunner: async () => {}, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + services: { "database.url": "postgres://localhost/unused" }, + }); + await degraded.initialize(); + assert.deepEqual( + Object.fromEntries(degraded.list().map(({ id, state }) => [id, state])), + { "healthy-after-schedule": "ready", schedule: "degraded" } + ); + assert.match(degraded.list().find(({ id }) => id === "schedule")!.error!.message, + /notifications\.send/); + await degraded.stop(); +}); From bf6bbc7f0b83d66122469b13c3d84ea356d52ce4 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:42:50 +0800 Subject: [PATCH 10/33] chore(trellis): archive schedule tasks --- .../2026-08}/08-24-schedule-calendar-view/check.jsonl | 0 .../2026-08}/08-24-schedule-calendar-view/design.md | 0 .../2026-08}/08-24-schedule-calendar-view/implement.jsonl | 0 .../2026-08}/08-24-schedule-calendar-view/implement.md | 0 .../2026-08}/08-24-schedule-calendar-view/prd.md | 0 .../2026-08}/08-24-schedule-calendar-view/task.json | 6 +++--- .../{ => archive/2026-08}/08-24-schedule-plugin/check.jsonl | 0 .../{ => archive/2026-08}/08-24-schedule-plugin/design.md | 0 .../2026-08}/08-24-schedule-plugin/implement.jsonl | 0 .../2026-08}/08-24-schedule-plugin/implement.md | 0 .../{ => archive/2026-08}/08-24-schedule-plugin/prd.md | 2 +- .../08-24-schedule-plugin/research/plugin-patterns.md | 0 .../{ => archive/2026-08}/08-24-schedule-plugin/task.json | 6 +++--- .../2026-08}/08-24-schedule-reminders/check.jsonl | 0 .../2026-08}/08-24-schedule-reminders/design.md | 0 .../2026-08}/08-24-schedule-reminders/implement.jsonl | 0 .../2026-08}/08-24-schedule-reminders/implement.md | 0 .../{ => archive/2026-08}/08-24-schedule-reminders/prd.md | 0 .../2026-08}/08-24-schedule-reminders/task.json | 6 +++--- README.md | 2 +- 20 files changed, 11 insertions(+), 11 deletions(-) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-calendar-view/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-calendar-view/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-calendar-view/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-calendar-view/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-calendar-view/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-calendar-view/task.json (82%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-plugin/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-plugin/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-plugin/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-plugin/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-plugin/prd.md (98%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-plugin/research/plugin-patterns.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-plugin/task.json (83%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-reminders/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-reminders/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-reminders/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-reminders/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-reminders/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-reminders/task.json (82%) diff --git a/.trellis/tasks/08-24-schedule-calendar-view/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-calendar-view/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/check.jsonl diff --git a/.trellis/tasks/08-24-schedule-calendar-view/design.md b/.trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/design.md similarity index 100% rename from .trellis/tasks/08-24-schedule-calendar-view/design.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/design.md diff --git a/.trellis/tasks/08-24-schedule-calendar-view/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-calendar-view/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/implement.jsonl diff --git a/.trellis/tasks/08-24-schedule-calendar-view/implement.md b/.trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/implement.md similarity index 100% rename from .trellis/tasks/08-24-schedule-calendar-view/implement.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/implement.md diff --git a/.trellis/tasks/08-24-schedule-calendar-view/prd.md b/.trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/prd.md similarity index 100% rename from .trellis/tasks/08-24-schedule-calendar-view/prd.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/prd.md diff --git a/.trellis/tasks/08-24-schedule-calendar-view/task.json b/.trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/task.json similarity index 82% rename from .trellis/tasks/08-24-schedule-calendar-view/task.json rename to .trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/task.json index 23cdf8b..9690829 100644 --- a/.trellis/tasks/08-24-schedule-calendar-view/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-calendar-view/task.json @@ -3,7 +3,7 @@ "name": "schedule-calendar-view", "title": "Schedule calendar views (#32)", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "frontend", "package": null, @@ -11,11 +11,11 @@ "creator": "codex", "assignee": "codex", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/schedule-plugin", "base_branch": "main", "worktree_path": null, - "commit": null, + "commit": "e2becaa266f9a6f390ea989a65d633e87fbd7a3c", "pr_url": null, "subtasks": [], "children": [], diff --git a/.trellis/tasks/08-24-schedule-plugin/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-plugin/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-plugin/check.jsonl diff --git a/.trellis/tasks/08-24-schedule-plugin/design.md b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/design.md similarity index 100% rename from .trellis/tasks/08-24-schedule-plugin/design.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-plugin/design.md diff --git a/.trellis/tasks/08-24-schedule-plugin/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-plugin/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-plugin/implement.jsonl diff --git a/.trellis/tasks/08-24-schedule-plugin/implement.md b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/implement.md similarity index 100% rename from .trellis/tasks/08-24-schedule-plugin/implement.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-plugin/implement.md diff --git a/.trellis/tasks/08-24-schedule-plugin/prd.md b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/prd.md similarity index 98% rename from .trellis/tasks/08-24-schedule-plugin/prd.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-plugin/prd.md index 074d891..552e539 100644 --- a/.trellis/tasks/08-24-schedule-plugin/prd.md +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/prd.md @@ -52,7 +52,7 @@ cross-child contract and final integration for GitHub Issues #31 and #32. root after integration. - [x] An independent check agent reviews the integrated diff after all three implementation agents finish, and verified findings are resolved. -- [ ] Changes are committed on `codex/schedule-plugin` without merging any +- [x] Changes are committed on `codex/schedule-plugin` without merging any sibling branch. ## Child Map diff --git a/.trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/research/plugin-patterns.md similarity index 100% rename from .trellis/tasks/08-24-schedule-plugin/research/plugin-patterns.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-plugin/research/plugin-patterns.md diff --git a/.trellis/tasks/08-24-schedule-plugin/task.json b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/task.json similarity index 83% rename from .trellis/tasks/08-24-schedule-plugin/task.json rename to .trellis/tasks/archive/2026-08/08-24-schedule-plugin/task.json index 18b61ef..870353b 100644 --- a/.trellis/tasks/08-24-schedule-plugin/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-plugin/task.json @@ -3,7 +3,7 @@ "name": "schedule-plugin", "title": "Schedule bundled plugin", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "cross-layer", "package": null, @@ -11,11 +11,11 @@ "creator": "codex", "assignee": "codex", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/schedule-plugin", "base_branch": "main", "worktree_path": null, - "commit": null, + "commit": "e2becaa266f9a6f390ea989a65d633e87fbd7a3c", "pr_url": null, "subtasks": [], "children": [ diff --git a/.trellis/tasks/08-24-schedule-reminders/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-reminders/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-reminders/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-reminders/check.jsonl diff --git a/.trellis/tasks/08-24-schedule-reminders/design.md b/.trellis/tasks/archive/2026-08/08-24-schedule-reminders/design.md similarity index 100% rename from .trellis/tasks/08-24-schedule-reminders/design.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-reminders/design.md diff --git a/.trellis/tasks/08-24-schedule-reminders/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-reminders/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-reminders/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-reminders/implement.jsonl diff --git a/.trellis/tasks/08-24-schedule-reminders/implement.md b/.trellis/tasks/archive/2026-08/08-24-schedule-reminders/implement.md similarity index 100% rename from .trellis/tasks/08-24-schedule-reminders/implement.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-reminders/implement.md diff --git a/.trellis/tasks/08-24-schedule-reminders/prd.md b/.trellis/tasks/archive/2026-08/08-24-schedule-reminders/prd.md similarity index 100% rename from .trellis/tasks/08-24-schedule-reminders/prd.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-reminders/prd.md diff --git a/.trellis/tasks/08-24-schedule-reminders/task.json b/.trellis/tasks/archive/2026-08/08-24-schedule-reminders/task.json similarity index 82% rename from .trellis/tasks/08-24-schedule-reminders/task.json rename to .trellis/tasks/archive/2026-08/08-24-schedule-reminders/task.json index 611b39f..919ce3c 100644 --- a/.trellis/tasks/08-24-schedule-reminders/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-reminders/task.json @@ -3,7 +3,7 @@ "name": "schedule-reminders", "title": "Schedule data and reminders (#31)", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "backend-cli", "package": null, @@ -11,11 +11,11 @@ "creator": "codex", "assignee": "codex", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/schedule-plugin", "base_branch": "main", "worktree_path": null, - "commit": null, + "commit": "e2becaa266f9a6f390ea989a65d633e87fbd7a3c", "pr_url": null, "subtasks": [], "children": [], diff --git a/README.md b/README.md index a28ddcf..14fbabc 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 m - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 -- **schedule**:以同一套日程数据提供显式确认开始、延后提醒、完成/取消,以及月/周/日视图;到点只提醒,绝不自动启动或创建 Core record。它只通过 Host 的 `notifications.send` 命名服务投递,能力缺失时仅本插件 degraded。实现追踪见 [Issue #31](https://github.com/CubePlus1/echolog/issues/31)、[Issue #32](https://github.com/CubePlus1/echolog/issues/32) 与 [Trellis 父任务](.trellis/tasks/08-24-schedule-plugin/)。 +- **schedule**:以同一套日程数据提供显式确认开始、延后提醒、完成/取消,以及月/周/日视图;到点只提醒,绝不自动启动或创建 Core record。它只通过 Host 的 `notifications.send` 命名服务投递,能力缺失时仅本插件 degraded。实现追踪见 [Issue #31](https://github.com/CubePlus1/echolog/issues/31)、[Issue #32](https://github.com/CubePlus1/echolog/issues/32) 与 [Trellis 父任务](.trellis/tasks/archive/2026-08/08-24-schedule-plugin/)。 插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。 From 8ff6581611ac77385b6ade572a9bf096c361a778 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:44:30 +0800 Subject: [PATCH 11/33] Revert "chore(task): archive inspiration plugin tasks" This reverts commit 6e42f207d4151b6436f123385cb7f250d34eca04. --- .../2026-08 => }/08-24-inspiration-capture/check.jsonl | 0 .../{archive/2026-08 => }/08-24-inspiration-capture/design.md | 0 .../2026-08 => }/08-24-inspiration-capture/implement.jsonl | 0 .../2026-08 => }/08-24-inspiration-capture/implement.md | 0 .../{archive/2026-08 => }/08-24-inspiration-capture/prd.md | 0 .../{archive/2026-08 => }/08-24-inspiration-capture/task.json | 4 ++-- .../2026-08 => }/08-24-inspiration-clients/check.jsonl | 0 .../{archive/2026-08 => }/08-24-inspiration-clients/design.md | 0 .../2026-08 => }/08-24-inspiration-clients/implement.jsonl | 0 .../2026-08 => }/08-24-inspiration-clients/implement.md | 0 .../{archive/2026-08 => }/08-24-inspiration-clients/prd.md | 0 .../{archive/2026-08 => }/08-24-inspiration-clients/task.json | 4 ++-- .../{archive/2026-08 => }/08-24-inspiration-flow/check.jsonl | 0 .../{archive/2026-08 => }/08-24-inspiration-flow/design.md | 0 .../2026-08 => }/08-24-inspiration-flow/implement.jsonl | 0 .../{archive/2026-08 => }/08-24-inspiration-flow/implement.md | 0 .../tasks/{archive/2026-08 => }/08-24-inspiration-flow/prd.md | 0 .../{archive/2026-08 => }/08-24-inspiration-flow/task.json | 4 ++-- .../2026-08 => }/08-24-inspiration-plugin/check.jsonl | 0 .../{archive/2026-08 => }/08-24-inspiration-plugin/design.md | 0 .../2026-08 => }/08-24-inspiration-plugin/implement.jsonl | 0 .../2026-08 => }/08-24-inspiration-plugin/implement.md | 0 .../{archive/2026-08 => }/08-24-inspiration-plugin/prd.md | 0 .../08-24-inspiration-plugin/research/plugin-patterns.md | 0 .../{archive/2026-08 => }/08-24-inspiration-plugin/task.json | 4 ++-- 25 files changed, 8 insertions(+), 8 deletions(-) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-capture/check.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-capture/design.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-capture/implement.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-capture/implement.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-capture/prd.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-capture/task.json (91%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-clients/check.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-clients/design.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-clients/implement.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-clients/implement.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-clients/prd.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-clients/task.json (91%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-flow/check.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-flow/design.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-flow/implement.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-flow/implement.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-flow/prd.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-flow/task.json (91%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-plugin/check.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-plugin/design.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-plugin/implement.jsonl (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-plugin/implement.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-plugin/prd.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-plugin/research/plugin-patterns.md (100%) rename .trellis/tasks/{archive/2026-08 => }/08-24-inspiration-plugin/task.json (92%) diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl b/.trellis/tasks/08-24-inspiration-capture/check.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl rename to .trellis/tasks/08-24-inspiration-capture/check.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md b/.trellis/tasks/08-24-inspiration-capture/design.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md rename to .trellis/tasks/08-24-inspiration-capture/design.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl b/.trellis/tasks/08-24-inspiration-capture/implement.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl rename to .trellis/tasks/08-24-inspiration-capture/implement.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md b/.trellis/tasks/08-24-inspiration-capture/implement.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md rename to .trellis/tasks/08-24-inspiration-capture/implement.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md b/.trellis/tasks/08-24-inspiration-capture/prd.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md rename to .trellis/tasks/08-24-inspiration-capture/prd.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json b/.trellis/tasks/08-24-inspiration-capture/task.json similarity index 91% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json rename to .trellis/tasks/08-24-inspiration-capture/task.json index 22614ad..f091446 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json +++ b/.trellis/tasks/08-24-inspiration-capture/task.json @@ -3,7 +3,7 @@ "name": "inspiration-capture", "title": "Inspiration capture and organization (#33)", "description": "", - "status": "completed", + "status": "in_progress", "dev_type": null, "scope": "plugin package metadata, schema, migrations, capture store/routes/tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": "2026-08-24", + "completedAt": null, "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl b/.trellis/tasks/08-24-inspiration-clients/check.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl rename to .trellis/tasks/08-24-inspiration-clients/check.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md b/.trellis/tasks/08-24-inspiration-clients/design.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md rename to .trellis/tasks/08-24-inspiration-clients/design.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl b/.trellis/tasks/08-24-inspiration-clients/implement.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl rename to .trellis/tasks/08-24-inspiration-clients/implement.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md b/.trellis/tasks/08-24-inspiration-clients/implement.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md rename to .trellis/tasks/08-24-inspiration-clients/implement.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md b/.trellis/tasks/08-24-inspiration-clients/prd.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md rename to .trellis/tasks/08-24-inspiration-clients/prd.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json b/.trellis/tasks/08-24-inspiration-clients/task.json similarity index 91% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json rename to .trellis/tasks/08-24-inspiration-clients/task.json index 2826f79..e9b8f7b 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json +++ b/.trellis/tasks/08-24-inspiration-clients/task.json @@ -3,7 +3,7 @@ "name": "inspiration-clients", "title": "Inspiration CLI Web and report clients", "description": "", - "status": "completed", + "status": "in_progress", "dev_type": null, "scope": "CLI, Web contribution, report-facing helper, client tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": "2026-08-24", + "completedAt": null, "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/08-24-inspiration-flow/check.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl rename to .trellis/tasks/08-24-inspiration-flow/check.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md b/.trellis/tasks/08-24-inspiration-flow/design.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md rename to .trellis/tasks/08-24-inspiration-flow/design.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/08-24-inspiration-flow/implement.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl rename to .trellis/tasks/08-24-inspiration-flow/implement.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md b/.trellis/tasks/08-24-inspiration-flow/implement.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md rename to .trellis/tasks/08-24-inspiration-flow/implement.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md b/.trellis/tasks/08-24-inspiration-flow/prd.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md rename to .trellis/tasks/08-24-inspiration-flow/prd.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json b/.trellis/tasks/08-24-inspiration-flow/task.json similarity index 91% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json rename to .trellis/tasks/08-24-inspiration-flow/task.json index 41c781a..9a0fd30 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json +++ b/.trellis/tasks/08-24-inspiration-flow/task.json @@ -3,7 +3,7 @@ "name": "inspiration-flow", "title": "Inspiration Flow surfacing (#34)", "description": "", - "status": "completed", + "status": "in_progress", "dev_type": null, "scope": "selector, flow store/service/routes/job/notification contract/tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": "2026-08-24", + "completedAt": null, "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl b/.trellis/tasks/08-24-inspiration-plugin/check.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl rename to .trellis/tasks/08-24-inspiration-plugin/check.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md b/.trellis/tasks/08-24-inspiration-plugin/design.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md rename to .trellis/tasks/08-24-inspiration-plugin/design.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl b/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl rename to .trellis/tasks/08-24-inspiration-plugin/implement.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md b/.trellis/tasks/08-24-inspiration-plugin/implement.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md rename to .trellis/tasks/08-24-inspiration-plugin/implement.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md b/.trellis/tasks/08-24-inspiration-plugin/prd.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md rename to .trellis/tasks/08-24-inspiration-plugin/prd.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md b/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md rename to .trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json b/.trellis/tasks/08-24-inspiration-plugin/task.json similarity index 92% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json rename to .trellis/tasks/08-24-inspiration-plugin/task.json index 02f4a09..1b798f4 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json +++ b/.trellis/tasks/08-24-inspiration-plugin/task.json @@ -3,7 +3,7 @@ "name": "inspiration-plugin", "title": "Inspiration bundled plugin", "description": "", - "status": "completed", + "status": "in_progress", "dev_type": null, "scope": "plugins/inspiration + bundled registry/build/docs tracking", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": "2026-08-24", + "completedAt": null, "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, From 8484b48cc29bb7832a5c1a601efa8c319b4f5415 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 02:11:18 +0800 Subject: [PATCH 12/33] feat(plugins): add permission-gated notification service --- .trellis/spec/backend/index.md | 1 + .../spec/backend/plugin-api-guidelines.md | 54 ++++ README.md | 2 + docs/PLUGIN_API.md | 53 +++- .../plugin-sdk/echolog-plugin.schema.json | 4 +- packages/plugin-sdk/src/index.ts | 59 +++- src/core/notifier.ts | 221 +++++++++++-- src/core/plugins/create.ts | 7 +- src/core/plugins/host.ts | 19 +- tests/plugin-notification-host.test.ts | 232 ++++++++++++++ tests/plugin-notifier.test.ts | 295 ++++++++++++++++++ tests/plugin-sdk.test.ts | 54 ++++ 12 files changed, 963 insertions(+), 38 deletions(-) create mode 100644 .trellis/spec/backend/plugin-api-guidelines.md create mode 100644 tests/plugin-notification-host.test.ts create mode 100644 tests/plugin-notifier.test.ts diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index fdff96f..55ed471 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -20,6 +20,7 @@ This directory contains guidelines for backend development. Fill in each file wi | [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | Done | | [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill | | [CLI Agent Contract](./cli-agent-contract.md) | `el` CLI as the agent tool surface: --json, exit codes, help-as-spec | Done | +| [Bundled Plugin API Guidelines](./plugin-api-guidelines.md) | Named Core services, permissions, privacy, and compatibility | Done | --- diff --git a/.trellis/spec/backend/plugin-api-guidelines.md b/.trellis/spec/backend/plugin-api-guidelines.md new file mode 100644 index 0000000..5377216 --- /dev/null +++ b/.trellis/spec/backend/plugin-api-guidelines.md @@ -0,0 +1,54 @@ +# Bundled Plugin API Guidelines + +> How additive Core services cross the Bundled Plugin API v1 boundary. + +## Named Core services + +Plugin capabilities that need Core-owned behavior use an exact named service +through `PluginContext.service(...)`. Do not add a general event bus, expose the +Fastify instance, expose the Core Drizzle handle, or let a plugin import/write +Core table schemas. + +Every privileged service name MUST have one manifest permission and one Host +enforcement mapping. Keep these layers synchronized in the same change: + +1. SDK service request/result types and permission vocabulary; +2. `echolog-plugin.schema.json` permission enumeration; +3. `validatePluginManifest` runtime validation; +4. Host named-service permission mapping and Core injection; +5. `docs/PLUGIN_API.md` and contract tests. + +Authorization failures throw a structured `PluginError` with +`PLUGIN_DEPENDENCY_MISSING` and identify the requesting plugin. Check permission +before revealing whether a privileged service is installed. Disabled plugin +lifecycle hooks never run; a bad service request during startup degrades only +that plugin and initialization continues with later plugins. + +## Notification service pattern + +`notifications.send` requires `notifications:send`. The plugin receives only a +typed send function. Core retains global/channel enablement, ntfy server/topic, +credentials, delivery timeouts, and transport dependencies. + +Operational delivery outcomes are data, not swallowed exceptions: return both +`mac` and `ntfy` with `sent`, `disabled`, or `failed`. Failed results contain a +bounded, non-sensitive error and never include endpoint URLs, topics, response +bodies, or notification content. A channel failure must not erase the other +channel's outcome. + +Bound transport waits with a rejecting timeout race even when an underlying +operation ignores `AbortSignal`; also honor the caller signal and remove timers +and listeners after settlement. Existing Core fire-and-forget callers may keep +a `void` compatibility wrapper, but plugin-facing calls use the result-bearing +primitive so delivery failures remain observable. + +## Compatibility checklist + +- Treat v1 additions as additive: preserve existing generic service calls, + bundled manifests, scheduler call signatures, routes, and lifecycle order. +- Test permission denied and allowed paths, disabled hooks, degraded-plugin + isolation, per-channel outcomes, non-2xx responses, abort/timeout behavior, + and legacy caller compatibility. +- Run the SDK test/build before root tests when workspace packages have not yet + produced their `dist` type entrypoints; finish with root `test`, `typecheck`, + and `build`. diff --git a/README.md b/README.md index e1b060c..1a2838e 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ screen-understanding 已接通 Provider/Keychain 管理、macOS 原生截图助 EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 manifest 标识,独立注册路由、定时任务、迁移、配置校验、健康检查和 Web 资源;插件初始化、迁移或采集失败会将对应插件置为 degraded,不阻断 Core 启动或主动记录。插件 Web 模块只能通过宿主提供的同源 HTTP API 读写数据,不能直连数据库。 +Plugin API v1 的通知 named service 由 [GitHub Issue #35](https://github.com/CubePlus1/echolog/issues/35) 追踪,作为 [#31 日程插件](https://github.com/CubePlus1/echolog/issues/31)、[#33 灵感记录](https://github.com/CubePlus1/echolog/issues/33) 与 [#34 灵感推送](https://github.com/CubePlus1/echolog/issues/34) 的共享 Core 前置能力。 + - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 - **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/)。 diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 2adc8b1..3226529 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -59,10 +59,12 @@ restrict Host APIs and make review scope explicit: | --- | --- | | `process:exec` | Bounded `execFile` command runner; no shell | | `database:plugin` | Database URL for plugin-owned tables | +| `notifications:send` | Core-owned `notifications.send` delivery service | A plugin without the corresponding declaration receives a structured `PLUGIN_DEPENDENCY_MISSING` error. Plugins MUST NOT import Core table schemas or -write Core records directly. +write Core records directly. Manifests that declare any permission outside this +fixed vocabulary are invalid. ## Lifecycle @@ -109,6 +111,53 @@ ignores `AbortSignal` cannot leave the job permanently marked as running. 64 KiB). It is written directly to child stdin and MUST NOT be copied into argv, environment variables, logs, or errors. Execution remains no-shell. +### Notification service + +A plugin that declares `notifications:send` obtains the exact named service +from its context: + +```ts +const sendNotification = context.service("notifications.send"); +const result = await sendNotification( + { + title: "Reminder", + message: "Stand-up starts in five minutes", + }, + signal +); +``` + +The request contains only `title` and `message`; the optional second argument +is an `AbortSignal`. The result reports the Core channels independently: + +```ts +{ + channels: { + mac: { status: "sent" }, + ntfy: { status: "failed", error: "Delivery failed" }, + }, +} +``` + +Each `mac` and `ntfy` result is exactly one of `sent`, `disabled`, or `failed`. +Only `failed` includes a bounded, non-sensitive `error` string. One channel's +failure does not erase the other channel's outcome. + +Notification configuration is a Core privacy boundary. Global and per-channel +enablement, ntfy server and topic, credentials, delivery timeouts, and +deployment details MUST NOT cross into plugin code. Plugins can observe only +the two channel outcomes above, never configuration or endpoint values. The +notification content itself is passed to the configured delivery channels and +MAY leave the local machine when ntfy is enabled, so a plugin MUST send only +content appropriate for that configured destination. + +The downstream schedule plugin tracked by GitHub Issue #31 declares +`notifications:send` and calls this service when a reminder becomes due. In the +inspiration recording/push flow tracked by Issues #33 and #34, recording and +storage remain plugin-owned and the push path calls this service only when a +stored inspiration is selected for delivery. Those plugins are downstream of +this API and are not implemented by the v1 service contract itself. + ## Routes and errors Canonical plugin routes use: @@ -141,7 +190,7 @@ Stable error codes: | `PLUGIN_DISABLED` | 503 | | `PLUGIN_DEGRADED` | 503 | | `PLUGIN_API_INCOMPATIBLE` | 503 | -| `PLUGIN_DEPENDENCY_MISSING` | 503 | +| `PLUGIN_DEPENDENCY_MISSING` | 403 | | `PLUGIN_EXEC_FAILED` | 502 | | `PLUGIN_TIMEOUT` | 504 | | `PLUGIN_OUTPUT_INVALID` | 502 | diff --git a/packages/plugin-sdk/echolog-plugin.schema.json b/packages/plugin-sdk/echolog-plugin.schema.json index a8b5865..dbab657 100644 --- a/packages/plugin-sdk/echolog-plugin.schema.json +++ b/packages/plugin-sdk/echolog-plugin.schema.json @@ -46,7 +46,9 @@ }, "permissions": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "enum": ["process:exec", "database:plugin", "notifications:send"] + }, "uniqueItems": true }, "requires": { diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index fd9103d..af81fd4 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,5 +1,13 @@ export const PLUGIN_API_VERSION = "1" as const; +export const SUPPORTED_PLUGIN_PERMISSIONS = [ + "process:exec", + "database:plugin", + "notifications:send", +] as const; + +export type PluginPermission = (typeof SUPPORTED_PLUGIN_PERMISSIONS)[number]; + export type PluginState = | "disabled" | "validating" @@ -31,7 +39,7 @@ export interface PluginManifest { web?: string; }; capabilities: string[]; - permissions: string[]; + permissions: PluginPermission[]; requires: { coreApi: string; platforms?: string[]; @@ -100,6 +108,33 @@ export interface PluginCommandResult { exitCode: number; } +export type PluginNotificationChannel = "mac" | "ntfy"; + +export interface PluginNotificationRequest { + title: string; + message: string; +} + +export type PluginNotificationChannelResult = + | { status: "sent" } + | { status: "disabled" } + | { status: "failed"; error: string }; + +export type PluginNotificationStatus = PluginNotificationChannelResult["status"]; + +export interface PluginNotificationResult { + channels: Record<PluginNotificationChannel, PluginNotificationChannelResult>; +} + +export type PluginNotificationSend = ( + request: PluginNotificationRequest, + signal?: AbortSignal +) => Promise<PluginNotificationResult>; + +export interface PluginCoreServices { + "notifications.send": PluginNotificationSend; +} + export interface PluginLogger { debug(fields: unknown, message?: string): void; info(fields: unknown, message?: string): void; @@ -122,6 +157,7 @@ export interface PluginContext { registerJob(job: PluginJob): void; registerReportSection(section: PluginReportSection): void; exec(request: PluginCommandRequest, signal?: AbortSignal): Promise<PluginCommandResult>; + service(name: "notifications.send"): PluginCoreServices["notifications.send"]; service<T>(name: string): T; } @@ -150,7 +186,7 @@ export interface PluginRuntimeInfo { enabled: boolean; state: PluginState; capabilities: string[]; - permissions: string[]; + permissions: PluginPermission[]; webEntry?: string; error?: { code: PluginErrorCode; @@ -176,6 +212,9 @@ export class PluginError extends Error { const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const SUPPORTED_PLUGIN_PERMISSION_SET = new Set<string>( + SUPPORTED_PLUGIN_PERMISSIONS +); export function validatePluginManifest(manifest: PluginManifest): string[] { const errors: string[] = []; @@ -196,8 +235,20 @@ export function validatePluginManifest(manifest: PluginManifest): string[] { ] as const) { if (!Array.isArray(values)) { errors.push(`${name} must be an array`); - } else if (new Set(values).size !== values.length) { - errors.push(`${name} must not contain duplicates`); + } else { + if (new Set(values).size !== values.length) { + errors.push(`${name} must not contain duplicates`); + } + if (name === "permissions") { + const unsupported = values.filter( + (permission) => !SUPPORTED_PLUGIN_PERMISSION_SET.has(permission) + ); + if (unsupported.length > 0) { + errors.push( + `permissions contains unsupported values: ${unsupported.join(", ")}` + ); + } + } } } return errors; diff --git a/src/core/notifier.ts b/src/core/notifier.ts index 795ec07..016ff78 100644 --- a/src/core/notifier.ts +++ b/src/core/notifier.ts @@ -1,38 +1,211 @@ import notifier from "node-notifier"; -import { loadConfig } from "./config.js"; +import type { + PluginNotificationChannelResult, + PluginNotificationRequest, + PluginNotificationResult, +} from "@echolog/plugin-sdk"; +import { loadConfig, type Config } from "./config.js"; -export function notifyMac(title: string, message: string) { - const config = loadConfig(); - if (!config.notifications.enabled || !config.notifications.mac) return; +const DELIVERY_TIMEOUT_MS = 5_000; +const MAX_ERROR_LENGTH = 160; - notifier.notify({ - title: `EchoLog: ${title}`, - message, - sound: "default", - timeout: 10, +export interface MacNotificationOptions { + title: string; + message: string; + sound: string; + timeout: number; +} + +export type MacNotify = ( + options: MacNotificationOptions, + callback: (error: Error | null) => void +) => void; + +export type NotificationFetch = ( + input: string | URL | Request, + init?: RequestInit +) => Promise<Pick<Response, "ok" | "status">>; + +export interface NotificationDependencies { + loadConfig?: () => Config; + macNotify?: MacNotify; + fetch?: NotificationFetch; + timeoutMs?: number; +} + +class DeliveryAbortedError extends Error {} +class DeliveryTimeoutError extends Error {} + +function failed(error: string): PluginNotificationChannelResult { + return { + status: "failed", + error: error.slice(0, MAX_ERROR_LENGTH), + }; +} + +function failureResult( + channel: "mac" | "ntfy", + error: unknown +): PluginNotificationChannelResult { + if (error instanceof DeliveryTimeoutError) { + return failed(`${channel} notification timed out`); + } + if (error instanceof DeliveryAbortedError) { + return failed(`${channel} notification aborted`); + } + return failed(`${channel} notification failed`); +} + +function runBounded<T>( + operation: (signal: AbortSignal) => Promise<T>, + callerSignal: AbortSignal | undefined, + timeoutMs: number +): Promise<T> { + return new Promise<T>((resolve, reject) => { + const controller = new AbortController(); + let settled = false; + let timer: ReturnType<typeof setTimeout> | undefined; + + const cleanup = () => { + if (timer) clearTimeout(timer); + callerSignal?.removeEventListener("abort", onAbort); + }; + const finish = (result: { value: T } | { error: unknown }) => { + if (settled) return; + settled = true; + cleanup(); + if ("error" in result) reject(result.error); + else resolve(result.value); + }; + const onAbort = () => { + controller.abort(); + finish({ error: new DeliveryAbortedError() }); + }; + + if (callerSignal?.aborted) { + onAbort(); + return; + } + callerSignal?.addEventListener("abort", onAbort, { once: true }); + timer = setTimeout(() => { + controller.abort(); + finish({ error: new DeliveryTimeoutError() }); + }, timeoutMs); + + Promise.resolve() + .then(() => operation(controller.signal)) + .then( + (value) => finish({ value }), + (error: unknown) => finish({ error }) + ); }); } -export async function notifyNtfy(title: string, message: string) { - const config = loadConfig(); - if (!config.notifications.enabled || !config.notifications.ntfy.enabled) - return; +const defaultMacNotify: MacNotify = (options, callback) => { + notifier.notify(options, (error) => callback(error)); +}; - const { server, topic } = config.notifications.ntfy; - const url = `${server}/${topic}`; +async function sendMac( + request: PluginNotificationRequest, + signal: AbortSignal | undefined, + dependencies: Required< + Pick<NotificationDependencies, "macNotify" | "timeoutMs"> + > +): Promise<PluginNotificationChannelResult> { + try { + await runBounded( + () => + new Promise<void>((resolve, reject) => { + dependencies.macNotify( + { + title: `EchoLog: ${request.title}`, + message: request.message, + sound: "default", + timeout: 10, + }, + (error) => (error ? reject(error) : resolve()) + ); + }), + signal, + dependencies.timeoutMs + ); + return { status: "sent" }; + } catch (error) { + return failureResult("mac", error); + } +} +async function sendNtfy( + request: PluginNotificationRequest, + config: Config, + signal: AbortSignal | undefined, + dependencies: Required< + Pick<NotificationDependencies, "fetch" | "timeoutMs"> + > +): Promise<PluginNotificationChannelResult> { try { - await fetch(url, { - method: "POST", - headers: { Title: `EchoLog: ${title}` }, - body: message, - }); + const response = await runBounded( + (deliverySignal) => { + const { server, topic } = config.notifications.ntfy; + return dependencies.fetch(`${server}/${topic}`, { + method: "POST", + headers: { Title: `EchoLog: ${request.title}` }, + body: request.message, + signal: deliverySignal, + }); + }, + signal, + dependencies.timeoutMs + ); + if (!response.ok) { + return failed(`ntfy notification failed with HTTP ${response.status}`); + } + return { status: "sent" }; + } catch (error) { + return failureResult("ntfy", error); + } +} + +export async function sendNotification( + request: PluginNotificationRequest, + signal?: AbortSignal, + dependencies: NotificationDependencies = {} +): Promise<PluginNotificationResult> { + let config: Config; + try { + config = (dependencies.loadConfig ?? loadConfig)(); } catch { - // ntfy unavailable, fail silently + const unavailable = failed("notification configuration unavailable"); + return { channels: { mac: unavailable, ntfy: unavailable } }; + } + + if (!config.notifications.enabled) { + return { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "disabled" }, + }, + }; } + + const timeoutMs = dependencies.timeoutMs ?? DELIVERY_TIMEOUT_MS; + const mac = config.notifications.mac + ? sendMac(request, signal, { + macNotify: dependencies.macNotify ?? defaultMacNotify, + timeoutMs, + }) + : Promise.resolve<PluginNotificationChannelResult>({ status: "disabled" }); + const ntfy = config.notifications.ntfy.enabled + ? sendNtfy(request, config, signal, { + fetch: dependencies.fetch ?? globalThis.fetch, + timeoutMs, + }) + : Promise.resolve<PluginNotificationChannelResult>({ status: "disabled" }); + const [macResult, ntfyResult] = await Promise.all([mac, ntfy]); + + return { channels: { mac: macResult, ntfy: ntfyResult } }; } -export function notify(title: string, message: string) { - notifyMac(title, message); - notifyNtfy(title, message).catch(() => {}); +export function notify(title: string, message: string): void { + void sendNotification({ title, message }).catch(() => {}); } diff --git a/src/core/plugins/create.ts b/src/core/plugins/create.ts index 7cf8eae..d348fbb 100644 --- a/src/core/plugins/create.ts +++ b/src/core/plugins/create.ts @@ -1,5 +1,6 @@ -import type { PluginLogger } from "@echolog/plugin-sdk"; +import type { PluginLogger, PluginNotificationSend } from "@echolog/plugin-sdk"; import { getDbUrl, type Config } from "../config.js"; +import { sendNotification } from "../notifier.js"; import { runPluginCommand } from "./command-runner.js"; import { PluginHost } from "./host.js"; import { runPluginMigrations } from "./migrations.js"; @@ -12,6 +13,9 @@ export function createPluginHost(config: Config, logger: PluginLogger): PluginHo "config.tracker is deprecated; migrate it to plugins.screen-time.config" ); } + const sendPluginNotification: PluginNotificationSend = (request, signal) => + sendNotification(request, signal, { loadConfig: () => config }); + return new PluginHost({ definitions: bundledPlugins, configuration: config.plugins, @@ -20,6 +24,7 @@ export function createPluginHost(config: Config, logger: PluginLogger): PluginHo commandRunner: runPluginCommand, services: { "database.url": getDbUrl(config), + "notifications.send": sendPluginNotification, }, }); } diff --git a/src/core/plugins/host.ts b/src/core/plugins/host.ts index 1b93e1c..e255746 100644 --- a/src/core/plugins/host.ts +++ b/src/core/plugins/host.ts @@ -8,6 +8,7 @@ import { type PluginDoctorCheck, type PluginJob, type PluginLogger, + type PluginPermission, type PluginReportSection, type PluginRoute, type PluginErrorCode, @@ -33,6 +34,11 @@ interface PluginJobRuntime { abortController: AbortController | null; } +const SERVICE_PERMISSIONS: Readonly<Record<string, PluginPermission>> = { + "database.url": "database:plugin", + "notifications.send": "notifications:send", +}; + export interface PluginHostOptions { definitions: readonly PluginDefinition[]; configuration?: Record< @@ -156,21 +162,22 @@ export class PluginHost { return options.commandRunner(request, signal); }, service: <T>(name: string): T => { - if (!Object.hasOwn(options.services ?? {}, name)) { - throw new Error(`Plugin service is not available: ${name}`); - } + const requiredPermission = SERVICE_PERMISSIONS[name]; if ( - name === "database.url" && - !definition.manifest.permissions.includes("database:plugin") + requiredPermission && + !definition.manifest.permissions.includes(requiredPermission) ) { throw new PluginError( "PLUGIN_DEPENDENCY_MISSING", - `Plugin ${id} has not declared database:plugin`, + `Plugin ${id} has not declared ${requiredPermission}`, id, info.state, 403 ); } + if (!Object.hasOwn(options.services ?? {}, name)) { + throw new Error(`Plugin service is not available: ${name}`); + } return options.services?.[name] as T; }, }; diff --git a/tests/plugin-notification-host.test.ts b/tests/plugin-notification-host.test.ts new file mode 100644 index 0000000..d221e6b --- /dev/null +++ b/tests/plugin-notification-host.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + PLUGIN_API_VERSION, + PluginError, + type PluginDefinition, + type PluginLogger, + type PluginManifest, +} from "@echolog/plugin-sdk"; +import { PluginHost } from "../src/core/plugins/host.js"; + +const logger: PluginLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +function manifest( + id: string, + permissions: PluginManifest["permissions"] = [] +): PluginManifest { + return { + manifestVersion: 1, + id, + version: "1.0.0", + apiVersion: PLUGIN_API_VERSION, + displayName: id, + description: `${id} notification test plugin`, + entries: { server: "./dist/server.js" }, + capabilities: [], + permissions, + requires: { coreApi: "^1.0.0" }, + }; +} + +function host( + definitions: PluginDefinition[], + services: Record<string, unknown> = {} +): PluginHost { + return new PluginHost({ + definitions, + logger, + migrationRunner: async () => {}, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + services, + }); +} + +test("denies notifications.send without its declared permission", async () => { + let serviceCalls = 0; + let deniedError: unknown; + const pluginHost = host( + [{ + manifest: manifest("notification-denied"), + defaultEnabled: true, + async start(context) { + try { + const send = context.service<( + request: { title: string; message: string } + ) => Promise<unknown>>("notifications.send"); + await send({ title: "private title", message: "private message" }); + } catch (error) { + deniedError = error; + throw error; + } + }, + }], + { + "notifications.send": async () => { + serviceCalls++; + }, + } + ); + + await pluginHost.initialize(); + + const [plugin] = pluginHost.list(); + assert.equal(plugin?.state, "degraded"); + assert.equal(plugin?.error?.code, "PLUGIN_DEPENDENCY_MISSING"); + assert.ok(deniedError instanceof PluginError); + assert.equal(deniedError.code, "PLUGIN_DEPENDENCY_MISSING"); + assert.equal(deniedError.statusCode, 403); + assert.equal(deniedError.pluginId, "notification-denied"); + assert.equal(serviceCalls, 0); +}); + +test("returns the Core-owned send function to a permitted plugin", async () => { + const requests: Array<{ title: string; message: string }> = []; + const expected = { + channels: { + mac: { status: "sent" as const }, + ntfy: { status: "disabled" as const }, + }, + }; + let received: unknown; + let receivedService: unknown; + const send = async (request: { title: string; message: string }) => { + requests.push(request); + return expected; + }; + const pluginHost = host( + [{ + manifest: manifest("notification-allowed", ["notifications:send"]), + defaultEnabled: true, + async start(context) { + const service = context.service<typeof send>("notifications.send"); + receivedService = service; + received = await service({ title: "Reminder", message: "Stand up" }); + }, + }], + { "notifications.send": send } + ); + + await pluginHost.initialize(); + + assert.equal(pluginHost.list()[0]?.state, "ready"); + assert.equal(receivedService, send); + assert.deepEqual(requests, [{ title: "Reminder", message: "Stand up" }]); + assert.equal(received, expected); +}); + +test("does not run notification lifecycle hooks for a disabled plugin", async () => { + let hooks = 0; + let serviceCalls = 0; + const pluginHost = host( + [{ + manifest: manifest("notification-disabled", ["notifications:send"]), + defaultEnabled: false, + register(context) { + hooks++; + context.service("notifications.send"); + }, + start(context) { + hooks++; + context.service("notifications.send"); + }, + }], + { + "notifications.send": async () => { + serviceCalls++; + }, + } + ); + + await pluginHost.initialize(); + + assert.equal(pluginHost.list()[0]?.state, "disabled"); + assert.equal(hooks, 0); + assert.equal(serviceCalls, 0); +}); + +test("isolates an unavailable notification service from later plugins", async () => { + let healthyStarted = false; + const pluginHost = host([ + { + manifest: manifest("notification-unavailable", ["notifications:send"]), + defaultEnabled: true, + start(context) { + context.service("notifications.send"); + }, + }, + { + manifest: manifest("notification-healthy"), + defaultEnabled: true, + start() { + healthyStarted = true; + }, + }, + ]); + + await pluginHost.initialize(); + + const states = Object.fromEntries( + pluginHost.list().map(({ id, state }) => [id, state]) + ); + assert.deepEqual(states, { + "notification-healthy": "ready", + "notification-unavailable": "degraded", + }); + assert.equal(healthyStarted, true); +}); + +test("one notification plugin failure does not block a healthy plugin", async () => { + let healthyResult: unknown; + const send = async (request: { title: string; message: string }) => { + if (request.title === "fail") throw new Error("delivery adapter unavailable"); + return { + channels: { + mac: { status: "sent" as const }, + ntfy: { status: "sent" as const }, + }, + }; + }; + const pluginHost = host( + [ + { + manifest: manifest("notification-broken", ["notifications:send"]), + defaultEnabled: true, + async start(context) { + await context.service<typeof send>("notifications.send")({ + title: "fail", + message: "first plugin", + }); + }, + }, + { + manifest: manifest("notification-working", ["notifications:send"]), + defaultEnabled: true, + async start(context) { + healthyResult = await context.service<typeof send>( + "notifications.send" + )({ title: "ok", message: "second plugin" }); + }, + }, + ], + { "notifications.send": send } + ); + + await pluginHost.initialize(); + + const states = Object.fromEntries( + pluginHost.list().map(({ id, state }) => [id, state]) + ); + assert.deepEqual(states, { + "notification-broken": "degraded", + "notification-working": "ready", + }); + assert.deepEqual(healthyResult, { + channels: { mac: { status: "sent" }, ntfy: { status: "sent" } }, + }); +}); diff --git a/tests/plugin-notifier.test.ts b/tests/plugin-notifier.test.ts new file mode 100644 index 0000000..0a420ba --- /dev/null +++ b/tests/plugin-notifier.test.ts @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { Config } from "../src/core/config.js"; +import { notify, sendNotification } from "../src/core/notifier.js"; + +function config( + notifications: Partial<Config["notifications"]> & { + ntfy?: Partial<Config["notifications"]["ntfy"]>; + } = {} +): Config { + return { + server: { port: 19827, host: "127.0.0.1" }, + database: { + host: "127.0.0.1", + port: 5432, + name: "echolog", + user: "echolog", + password: "not-used", + }, + sync: { target: "", auto: false }, + notifications: { + enabled: notifications.enabled ?? true, + mac: notifications.mac ?? true, + ntfy: { + enabled: notifications.ntfy?.enabled ?? true, + server: notifications.ntfy?.server ?? "https://ntfy.invalid", + topic: notifications.ntfy?.topic ?? "private-topic", + }, + rules: notifications.rules ?? { + task_overtime_minutes: 60, + idle_reminder_enabled: false, + idle_check_start: "09:00", + idle_check_end: "18:00", + daily_report_time: "18:00", + end_of_day_time: "19:00", + }, + }, + }; +} + +const request = { title: "Reminder", message: "Private notification body" }; + +test("reports both channels disabled when notifications are globally disabled", async () => { + let macCalls = 0; + let fetchCalls = 0; + + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ enabled: false }), + macNotify: () => { + macCalls++; + }, + fetch: async () => { + fetchCalls++; + return new Response(null, { status: 200 }); + }, + }); + + assert.deepEqual(result, { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "disabled" }, + }, + }); + assert.equal(macCalls, 0); + assert.equal(fetchCalls, 0); +}); + +test("reports a disabled channel independently from a sent channel", async () => { + let fetchedUrl = ""; + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ mac: false }), + macNotify: () => assert.fail("disabled mac channel must not be called"), + fetch: async (input) => { + fetchedUrl = String(input); + return new Response(null, { status: 204 }); + }, + }); + + assert.deepEqual(result, { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "sent" }, + }, + }); + assert.equal(fetchedUrl, "https://ntfy.invalid/private-topic"); +}); + +test("reports mac callback success and failure", async (t) => { + await t.test("success", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: (_options, callback) => callback(null), + }); + + assert.deepEqual(result.channels.mac, { status: "sent" }); + assert.deepEqual(result.channels.ntfy, { status: "disabled" }); + }); + + await t.test("failure", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: (_options, callback) => + callback(new Error("mac notification unavailable")), + }); + + assert.equal(result.channels.mac.status, "failed"); + assert.ok( + result.channels.mac.status === "failed" && + result.channels.mac.error.length > 0 && + result.channels.mac.error.length <= 200 + ); + assert.deepEqual(result.channels.ntfy, { status: "disabled" }); + }); +}); + +test("bounds a non-cooperative mac delivery with an internal timeout", async () => { + const startedAt = Date.now(); + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: () => {}, + timeoutMs: 10, + }); + + assert.equal(result.channels.mac.status, "failed"); + assert.ok(Date.now() - startedAt < 1_000, "delivery timeout must be bounded"); + assert.match( + result.channels.mac.status === "failed" ? result.channels.mac.error : "", + /timed out/i + ); +}); + +test("honors a caller-provided abort signal for mac delivery", async () => { + const controller = new AbortController(); + const delivery = sendNotification(request, controller.signal, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify: () => {}, + timeoutMs: 10_000, + }); + controller.abort(); + + const result = await delivery; + assert.equal(result.channels.mac.status, "failed"); + assert.match( + result.channels.mac.status === "failed" ? result.channels.mac.error : "", + /abort/i + ); +}); + +test("reports ntfy success, non-2xx, and network failures", async (t) => { + const ntfyOnly = () => config({ mac: false }); + + await t.test("success", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: ntfyOnly, + fetch: async () => new Response(null, { status: 201 }), + }); + assert.deepEqual(result.channels.ntfy, { status: "sent" }); + }); + + await t.test("non-2xx", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: ntfyOnly, + fetch: async () => + new Response("upstream-private-response", { status: 503 }), + }); + assert.equal(result.channels.ntfy.status, "failed"); + if (result.channels.ntfy.status === "failed") { + assert.match(result.channels.ntfy.error, /503/); + assert.equal(result.channels.ntfy.error.includes("private-topic"), false); + assert.equal( + result.channels.ntfy.error.includes("upstream-private-response"), + false + ); + assert.ok(result.channels.ntfy.error.length <= 200); + } + }); + + await t.test("network failure", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: ntfyOnly, + fetch: async () => { + throw new Error( + "network unavailable for https://ntfy.invalid/private-topic with Private notification body" + ); + }, + }); + assert.equal(result.channels.ntfy.status, "failed"); + if (result.channels.ntfy.status === "failed") { + assert.ok(result.channels.ntfy.error.length > 0); + assert.equal(result.channels.ntfy.error.includes("private-topic"), false); + assert.equal( + result.channels.ntfy.error.includes("Private notification body"), + false + ); + } + }); +}); + +test("aborts an in-flight ntfy transport when its delivery times out", async () => { + let transportSignal: AbortSignal | undefined; + const result = await sendNotification(request, undefined, { + loadConfig: () => config({ mac: false }), + fetch: async (_input, init) => { + transportSignal = init?.signal ?? undefined; + return new Promise<Pick<Response, "ok" | "status">>(() => {}); + }, + timeoutMs: 10, + }); + + assert.equal(transportSignal?.aborted, true); + assert.equal(result.channels.ntfy.status, "failed"); + assert.match( + result.channels.ntfy.status === "failed" ? result.channels.ntfy.error : "", + /timed out/i + ); +}); + +test("keeps channel results independent when one delivery fails", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: () => config(), + macNotify: (_options, callback) => + callback(new Error("mac unavailable")), + fetch: async () => new Response(null, { status: 200 }), + }); + + assert.equal(result.channels.mac.status, "failed"); + assert.deepEqual(result.channels.ntfy, { status: "sent" }); +}); + +test("legacy notify remains a void, non-rejecting fire-and-forget wrapper", async () => { + const directory = mkdtempSync(join(tmpdir(), "echolog-notifier-test-")); + const configPath = join(directory, "config.yaml"); + const originalConfigPath = process.env.ECHOLOG_CONFIG_PATH; + const originalFetch = globalThis.fetch; + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + + writeFileSync( + configPath, + [ + "server:", + " port: 19827", + " host: 127.0.0.1", + "database:", + " host: 127.0.0.1", + " port: 5432", + " name: echolog", + " user: echolog", + " password: not-used", + "sync:", + " target: ''", + " auto: false", + "notifications:", + " enabled: true", + " mac: false", + " ntfy:", + " enabled: true", + " server: https://ntfy.invalid", + " topic: private-topic", + " rules:", + " task_overtime_minutes: 60", + " idle_reminder_enabled: false", + " idle_check_start: '09:00'", + " idle_check_end: '18:00'", + " daily_report_time: '18:00'", + " end_of_day_time: '19:00'", + "", + ].join("\n") + ); + + try { + process.env.ECHOLOG_CONFIG_PATH = configPath; + globalThis.fetch = async () => { + throw new Error("simulated background delivery rejection"); + }; + process.on("unhandledRejection", onUnhandled); + + const returnValue: void = notify("Legacy", "Scheduler-compatible"); + assert.equal(returnValue, undefined); + await new Promise<void>((resolve) => setImmediate(resolve)); + assert.deepEqual(unhandled, []); + } finally { + process.off("unhandledRejection", onUnhandled); + globalThis.fetch = originalFetch; + if (originalConfigPath === undefined) { + delete process.env.ECHOLOG_CONFIG_PATH; + } else { + process.env.ECHOLOG_CONFIG_PATH = originalConfigPath; + } + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/plugin-sdk.test.ts b/tests/plugin-sdk.test.ts index 8b74e91..6efab95 100644 --- a/tests/plugin-sdk.test.ts +++ b/tests/plugin-sdk.test.ts @@ -1,11 +1,31 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { PLUGIN_API_VERSION, + SUPPORTED_PLUGIN_PERMISSIONS, validatePluginManifest, type PluginManifest, + type PluginPermission, } from "@echolog/plugin-sdk"; +interface ManifestSchema { + properties: { + permissions: { + items: { + enum: string[]; + }; + }; + }; +} + +const manifestSchema = JSON.parse( + readFileSync( + new URL("../packages/plugin-sdk/echolog-plugin.schema.json", import.meta.url), + "utf8" + ) +) as ManifestSchema; + function manifest(overrides: Partial<PluginManifest> = {}): PluginManifest { return { manifestVersion: 1, @@ -26,6 +46,15 @@ test("accepts a valid bundled plugin manifest", () => { assert.deepEqual(validatePluginManifest(manifest()), []); }); +test("accepts every supported plugin permission", () => { + assert.deepEqual( + validatePluginManifest( + manifest({ permissions: [...SUPPORTED_PLUGIN_PERMISSIONS] }) + ), + [] + ); +}); + test("rejects unstable ids, incompatible API versions, and duplicates", () => { const errors = validatePluginManifest( manifest({ @@ -39,3 +68,28 @@ test("rejects unstable ids, incompatible API versions, and duplicates", () => { assert.ok(errors.some((error) => error.includes("apiVersion"))); assert.ok(errors.some((error) => error.includes("duplicates"))); }); + +test("runtime validation rejects unknown plugin permissions", () => { + const errors = validatePluginManifest( + manifest({ permissions: ["notifications:read" as PluginPermission] }) + ); + + assert.ok( + errors.some( + (error) => + error === "permissions contains unsupported values: notifications:read" + ) + ); +}); + +test("manifest schema enumerates the exact supported permission vocabulary", () => { + assert.deepEqual( + manifestSchema.properties.permissions.items.enum, + [...SUPPORTED_PLUGIN_PERMISSIONS] + ); + assert.ok( + !manifestSchema.properties.permissions.items.enum.includes( + "notifications:read" + ) + ); +}); From ae65748fb4d96b77850b00807c2975f483283e05 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:03:12 +0800 Subject: [PATCH 13/33] fix(schedule): resolve local review findings --- .trellis/spec/backend/database-guidelines.md | 8 ++ .trellis/spec/frontend/directory-structure.md | 4 +- .../spec/guides/cross-layer-thinking-guide.md | 2 + .../08-24-schedule-review-fixes/check.jsonl | 4 + .../08-24-schedule-review-fixes/design.md | 23 ++++++ .../implement.jsonl | 4 + .../08-24-schedule-review-fixes/implement.md | 10 +++ .../tasks/08-24-schedule-review-fixes/prd.md | 32 ++++++++ .../08-24-schedule-review-fixes/task.json | 26 ++++++ plugins/schedule/src/index.ts | 6 ++ plugins/schedule/src/schema.ts | 4 + plugins/schedule/src/validation.ts | 5 +- plugins/schedule/web/index.js | 21 +++-- tests/schedule-web.test.ts | 80 +++++++++++++++++++ tests/schedule.test.ts | 24 ++++++ 15 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 .trellis/tasks/08-24-schedule-review-fixes/check.jsonl create mode 100644 .trellis/tasks/08-24-schedule-review-fixes/design.md create mode 100644 .trellis/tasks/08-24-schedule-review-fixes/implement.jsonl create mode 100644 .trellis/tasks/08-24-schedule-review-fixes/implement.md create mode 100644 .trellis/tasks/08-24-schedule-review-fixes/prd.md create mode 100644 .trellis/tasks/08-24-schedule-review-fixes/task.json diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index 40bc2d5..dc7bd75 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -57,6 +57,9 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl - Claim a reminder by inserting a unique ledger key before delivery. A ledger row in any state (`claimed`, `sent`, or `failed`) makes that exact item/reminder instant ineligible for another attempt. +- The ledger MUST index `(item_id, reminder_at)` in the same order used by the + due-query anti-join. A `dedupe_key` index cannot serve predicates on its + component columns, and the ledger grows for the lifetime of the plugin. - At-most-once means a crash after claim may lose one reminder; restart must not repeat a possibly delivered notification. A user action that chooses a new reminder instant creates a new key. @@ -92,6 +95,8 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl remaining candidates. - Assert `claimed`, `sent`, and `failed` ledger rows are all excluded before `LIMIT`; a new snooze instant remains eligible. +- Assert the immutable follow-up migration and Drizzle schema both declare the + `(item_id, reminder_at)` lookup index. - Assert failed/ignored delivery does not modify status, confirmed timestamp, or create a Core record. @@ -119,6 +124,9 @@ WHERE i.next_reminder_at <= NOW() ) ORDER BY i.next_reminder_at LIMIT 100; + +CREATE INDEX idx_schedule_reminder_deliveries_item_reminder + ON schedule_reminder_deliveries(item_id, reminder_at); ``` ## Common Mistakes diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md index 059233d..e373c23 100644 --- a/.trellis/spec/frontend/directory-structure.md +++ b/.trellis/spec/frontend/directory-structure.md @@ -44,5 +44,7 @@ web/ 是全局 `document.getElementById`。交互控件 id 与 action target 必须包含 face/surface 作用域(例如 `day:<encoded-item-id>`),handler 再安全还原真实 id;禁止仅用实体 id 生成控件 id,否则不可见页的同名控件会截获当前页输入。 + 同一规则也适用于错误/状态容器:handler 必须用 action target 的 surface 选择 + 当前 face 的错误元素,不能固定写 overview 的全局 id。 Web 测试须同时渲染两个 face,为两个控件设置不同值,并断言点击某一 face - 只读取该 face 的值且 API URL 只编码真实实体 id 一次。 + 只读取该 face 的值、错误只落在该 face,且 API URL 只编码真实实体 id 一次。 diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md index 9686546..0b4ae5f 100644 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -114,6 +114,8 @@ Before implementation: After implementation: - [ ] Tested with edge cases (null, empty, invalid) +- [ ] Reused the same boundary fixture cases for client preflight and server + validation (especially timestamps, offsets, enums, and nullable fields) - [ ] Verified error handling at each boundary - [ ] Checked data survives round-trip - [ ] Checked that consumers import shared decoders / projections instead of diff --git a/.trellis/tasks/08-24-schedule-review-fixes/check.jsonl b/.trellis/tasks/08-24-schedule-review-fixes/check.jsonl new file mode 100644 index 0000000..3a456ca --- /dev/null +++ b/.trellis/tasks/08-24-schedule-review-fixes/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Atomic/query/migration regression review."} +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Verify composite index and immutable migration sequencing."} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Verify errors target the active face."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify timestamp validation consistency."} diff --git a/.trellis/tasks/08-24-schedule-review-fixes/design.md b/.trellis/tasks/08-24-schedule-review-fixes/design.md new file mode 100644 index 0000000..97ad389 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-review-fixes/design.md @@ -0,0 +1,23 @@ +# Schedule local review fixes design + +## Web surface error routing + +Extend action-target parsing to retain the surface. All action status/error +writes choose `scheduleActionErrorDay` for `day:...` targets and +`scheduleActionError` otherwise. The existing global host `$` helper remains +unchanged. + +## Timestamp grammar + +The API is authoritative. Accept ISO timestamps with an explicit `Z` or +numeric offset at either minute or second precision; optional fractional +seconds remain valid only when seconds are present. Apply the same grammar in +the Web preflight and backend parser, with shared fixture cases in tests. + +## Ledger index + +Append an immutable `002_schedule_delivery_lookup_index` migration creating +`idx_schedule_reminder_deliveries_item_reminder` on +`(item_id, reminder_at)`. Add the matching Drizzle schema index. Do not edit +the already published `001` SQL because plugin migration checksums are +immutable once applied. diff --git a/.trellis/tasks/08-24-schedule-review-fixes/implement.jsonl b/.trellis/tasks/08-24-schedule-review-fixes/implement.jsonl new file mode 100644 index 0000000..e45eee4 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-review-fixes/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Immutable plugin migrations and reminder ledger query/index contract."} +{"file":".trellis/spec/backend/error-handling.md","reason":"Structured API errors and boundary validation behavior."} +{"file":".trellis/spec/frontend/directory-structure.md","reason":"Multi-face surface-scoped controls and global host lookup behavior."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Keep Web and API timestamp contracts aligned."} diff --git a/.trellis/tasks/08-24-schedule-review-fixes/implement.md b/.trellis/tasks/08-24-schedule-review-fixes/implement.md new file mode 100644 index 0000000..a89765e --- /dev/null +++ b/.trellis/tasks/08-24-schedule-review-fixes/implement.md @@ -0,0 +1,10 @@ +# Schedule local review fixes implementation plan + +1. Add Web surface-aware error routing and tests for day/overview API failures. +2. Align backend/Web timestamp regexes and add matching valid/invalid fixtures. +3. Append the composite-index migration and Drizzle schema declaration; assert + migration order and SQL in tests. +4. Run focused Web/backend tests, explicit PostgreSQL integration, then full + test/typecheck/build and diff review. +5. Update durable specs only if the fixes establish a new convention, commit, + record the actual hash, and archive this Trellis task. diff --git a/.trellis/tasks/08-24-schedule-review-fixes/prd.md b/.trellis/tasks/08-24-schedule-review-fixes/prd.md new file mode 100644 index 0000000..e9317b9 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-review-fixes/prd.md @@ -0,0 +1,32 @@ +# Fix Schedule local review findings + +## Goal + +Resolve all three actionable findings from the local base-branch review of the +Schedule bundled plugin without changing its product semantics or notification +service boundary. + +## Requirements + +- Day-face validation and API errors MUST render in the day error element; + overview actions MUST continue using the overview error element. +- Web and backend MUST accept/reject the same explicit-offset timestamp grammar. + Minute-precision ISO input such as `2026-08-25T09:00+08:00` MUST not pass one + layer and fail the other. +- The reminder ledger MUST have an index covering the exact + `(item_id, reminder_at)` anti-join used by due polling and the per-item ledger + query. Schema metadata and immutable plugin migrations MUST stay synchronized. +- Existing explicit-confirmation, at-most-once ledger, snooze, and + notifications.send contracts MUST remain unchanged. + +## Acceptance Criteria + +- [x] A day action failure updates only `scheduleActionErrorDay`; overview + failure updates only `scheduleActionError`. +- [x] Cross-layer tests cover minute-precision and second-precision offset + timestamps with the same outcome in Web and backend. +- [x] PostgreSQL migration/schema expose an item/reminder composite index and + the existing integration suite remains green. +- [x] Focused tests, PostgreSQL integration, `pnpm test`, `pnpm typecheck`, + and `pnpm build` pass. +- [ ] Fixes are committed on `codex/schedule-plugin` after final diff review. diff --git a/.trellis/tasks/08-24-schedule-review-fixes/task.json b/.trellis/tasks/08-24-schedule-review-fixes/task.json new file mode 100644 index 0000000..dfd00dc --- /dev/null +++ b/.trellis/tasks/08-24-schedule-review-fixes/task.json @@ -0,0 +1,26 @@ +{ + "id": "schedule-review-fixes", + "name": "schedule-review-fixes", + "title": "Fix Schedule local review findings", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": "cross-layer", + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/schedule-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/plugins/schedule/src/index.ts b/plugins/schedule/src/index.ts index 415a46c..575e73f 100644 --- a/plugins/schedule/src/index.ts +++ b/plugins/schedule/src/index.ts @@ -134,6 +134,12 @@ export const schedulePlugin: PluginDefinition = { CREATE INDEX IF NOT EXISTS idx_schedule_reminder_deliveries_attempted_at ON schedule_reminder_deliveries(attempted_at); `, + }, { + name: "002_schedule_delivery_lookup_index", + sql: ` + CREATE INDEX IF NOT EXISTS idx_schedule_reminder_deliveries_item_reminder + ON schedule_reminder_deliveries(item_id, reminder_at); + `, }], register(context) { currentStore = new ScheduleStore(context.service<string>("database.url")); diff --git a/plugins/schedule/src/schema.ts b/plugins/schedule/src/schema.ts index 7003774..161019c 100644 --- a/plugins/schedule/src/schema.ts +++ b/plugins/schedule/src/schema.ts @@ -112,6 +112,10 @@ export const scheduleReminderDeliveries = pgTable( uniqueIndex("idx_schedule_reminder_deliveries_dedupe_key").on( table.dedupeKey ), + index("idx_schedule_reminder_deliveries_item_reminder").on( + table.itemId, + table.reminderAt + ), index("idx_schedule_reminder_deliveries_attempted_at").on(table.attemptedAt), check( "schedule_reminder_deliveries_status_check", diff --git a/plugins/schedule/src/validation.ts b/plugins/schedule/src/validation.ts index 669366b..3835e05 100644 --- a/plugins/schedule/src/validation.ts +++ b/plugins/schedule/src/validation.ts @@ -5,7 +5,7 @@ import type { } from "./types.js"; const EXPLICIT_INSTANT_RE = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-](\d{2}):(\d{2}))$/; const ITEM_ID_RE = /^[A-Za-z0-9_-]{8,32}$/; const STATUSES = new Set<ScheduleStatus>([ "scheduled", @@ -77,7 +77,8 @@ export function parseExplicitInstant( year! < 1 || month! < 1 || month! > 12 || day! < 1 || day! > daysInMonth || - hour! > 23 || minute! > 59 || second! > 59 || + hour! > 23 || minute! > 59 || + (second !== undefined && second > 59) || (offsetHour !== undefined && offsetHour > 23) || (offsetMinute !== undefined && offsetMinute > 59) ) { diff --git a/plugins/schedule/web/index.js b/plugins/schedule/web/index.js index 2220329..8efa9ed 100644 --- a/plugins/schedule/web/index.js +++ b/plugins/schedule/web/index.js @@ -152,9 +152,15 @@ function parseActionTarget(value) { const target = String(value ?? ""); const separator = target.indexOf(":"); const surface = separator >= 0 ? target.slice(0, separator) : ""; - if (!ACTION_SURFACES.has(surface)) return { itemId: target, target }; + if (!ACTION_SURFACES.has(surface)) { + return { itemId: target, target, surface: "overview" }; + } try { - return { itemId: decodeURIComponent(target.slice(separator + 1)), target }; + return { + itemId: decodeURIComponent(target.slice(separator + 1)), + target, + surface, + }; } catch { return null; } @@ -316,7 +322,7 @@ function renderDay(items, referenceKey, context, now) { } function explicitOffsetInstant(value) { - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)); } @@ -419,6 +425,9 @@ export async function activate({ api, root, now: nowFactory = () => new Date() } if (!route) return { handled: false }; const parsedTarget = parseActionTarget(id); if (!parsedTarget) return { handled: true, refresh: false }; + const actionErrorId = parsedTarget.surface === "day" + ? "scheduleActionErrorDay" + : "scheduleActionError"; const item = latestItems.find((candidate) => candidate.id === parsedTarget.itemId); if (!item) return { handled: true, refresh: false }; if (action === "schedule-cancel" && !confirm("取消此日程?")) { @@ -428,7 +437,7 @@ export async function activate({ api, root, now: nowFactory = () => new Date() } if (action === "schedule-snooze") { const minutes = Number($(`scheduleSnooze:${parsedTarget.target}`)?.value ?? 10); if (!Number.isInteger(minutes) || minutes < 1 || minutes > 10080) { - setError($, "scheduleActionError", "稍后提醒须为 1 至 10080 分钟。"); + setError($, actionErrorId, "稍后提醒须为 1 至 10080 分钟。"); return { handled: true, refresh: false }; } body.nextReminderAt = new Date(nowFactory().getTime() + minutes * 60_000).toISOString(); @@ -439,7 +448,7 @@ export async function activate({ api, root, now: nowFactory = () => new Date() } body: JSON.stringify(body), }); replaceLatest(updated); - setError($, "scheduleActionError", ""); + setError($, actionErrorId, ""); const message = { "schedule-confirm-start": "已确认开始 · 行", "schedule-snooze": "提醒已顺延", @@ -448,7 +457,7 @@ export async function activate({ api, root, now: nowFactory = () => new Date() } }[action]; return { handled: true, message }; } catch (error) { - setError($, "scheduleActionError", error); + setError($, actionErrorId, error); return { handled: true, refresh: false }; } }, diff --git a/tests/schedule-web.test.ts b/tests/schedule-web.test.ts index 8ea61be..5103cac 100644 --- a/tests/schedule-web.test.ts +++ b/tests/schedule-web.test.ts @@ -367,6 +367,86 @@ test("Schedule scopes duplicate item controls by face and day snooze reads only }); }); +test("Schedule routes action errors to the originating overview or day face", async () => { + const scheduled = item({ + scheduledStartAt: "2026-08-23T23:30:00.000Z", + timezone: "Asia/Shanghai", + }); + const contribution = await activate({ + now: () => NOW, + api: async (_path: string, options?: unknown) => { + if (!options) return [scheduled]; + throw new Error("backend failed"); + }, + }); + const data = await contribution.load(); + const context = renderContext(data.scheduleItems); + const overview = contribution.renderFace({ type: "schedule-overview" }, context); + const day = contribution.renderFace({ type: "schedule-day" }, context); + const overviewTarget = overview.match( + /data-act="schedule-confirm-start" data-id="([^"]+)"/ + )?.[1]; + const dayTarget = day.match( + /data-act="schedule-confirm-start" data-id="([^"]+)"/ + )?.[1]; + assert.ok(overviewTarget); + assert.ok(dayTarget); + + const errors = { + scheduleActionError: { textContent: "" }, + scheduleActionErrorDay: { textContent: "" }, + }; + const $ = (id: string) => errors[id as keyof typeof errors] ?? null; + await contribution.handleAction("schedule-confirm-start", { + id: dayTarget, + $, + confirm: () => true, + }); + assert.equal(errors.scheduleActionError.textContent, ""); + assert.equal(errors.scheduleActionErrorDay.textContent, "backend failed"); + + errors.scheduleActionErrorDay.textContent = ""; + await contribution.handleAction("schedule-confirm-start", { + id: overviewTarget, + $, + confirm: () => true, + }); + assert.equal(errors.scheduleActionError.textContent, "backend failed"); + assert.equal(errors.scheduleActionErrorDay.textContent, ""); +}); + +test("Schedule Web accepts the same minute and second precision offsets as the API", async () => { + const calls: Array<{ path: string; options?: { body?: string } }> = []; + const contribution = await activate({ + now: () => NOW, + api: async (path: string, options?: { body?: string }) => { + calls.push({ path, options }); + return item(); + }, + }); + const elements: Record<string, { value?: string; textContent?: string }> = { + scheduleTitle: { value: "精度一致" }, + scheduleDescription: { value: "" }, + scheduleStart: { value: "2026-08-25T09:00+08:00" }, + scheduleEnd: { value: "2026-08-25T10:30:00.123456+08:00" }, + scheduleTimezone: { value: "Asia/Shanghai" }, + schedulePriority: { value: "0" }, + scheduleCreateError: { textContent: "" }, + }; + await contribution.handleAction("schedule-create", { + id: "", + $: (id: string) => elements[id] ?? null, + confirm: () => true, + }); + assert.equal(elements.scheduleCreateError.textContent, ""); + const payload = JSON.parse(calls.at(-1)?.options?.body ?? ""); + assert.equal(payload.scheduledStartAt, "2026-08-25T09:00+08:00"); + assert.equal( + payload.scheduledEndAt, + "2026-08-25T10:30:00.123456+08:00" + ); +}); + test("Schedule validates create and snooze locally without issuing a write", async () => { const calls: string[] = []; const contribution = await activate({ diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts index 76c0a92..c6c6b56 100644 --- a/tests/schedule.test.ts +++ b/tests/schedule.test.ts @@ -111,6 +111,7 @@ test("Schedule manifest, config, migrations, and imports preserve plugin boundar assert.deepEqual(schedulePlugin.migrations?.map(({ name }) => name), [ "001_schedule_items_and_reminder_deliveries", + "002_schedule_delivery_lookup_index", ]); const migration = schedulePlugin.migrations?.[0]?.sql ?? ""; assert.match(migration, /schedule_items/); @@ -120,6 +121,15 @@ test("Schedule manifest, config, migrations, and imports preserve plugin boundar assert.match(migration, /version >= 1/); assert.match(migration, /status IN \('scheduled', 'active', 'done', 'cancelled'\)/); assert.doesNotMatch(migration, /calendar_events|records|inspiration/i); + const lookupMigration = schedulePlugin.migrations?.[1]?.sql ?? ""; + assert.match( + lookupMigration, + /idx_schedule_reminder_deliveries_item_reminder/ + ); + assert.match( + lookupMigration, + /schedule_reminder_deliveries\(item_id, reminder_at\)/ + ); const sources = ["index.ts", "reminders.ts", "routes.ts", "store.ts", "types.ts"] .map((name) => readFileSync( @@ -145,8 +155,22 @@ test("Schedule boundary validation rejects local datetimes and unknown fields", assert.equal(valid.value.priority, 0); } + const minutePrecision = validateCreateScheduleItem({ + title: "Minute precision", + scheduledStartAt: "2026-08-24T10:00+08:00", + timezone: "Asia/Shanghai", + }); + assert.equal(minutePrecision.ok, true); + if (minutePrecision.ok) { + assert.equal( + minutePrecision.value.scheduledStartAt.toISOString(), + "2026-08-24T02:00:00.000Z" + ); + } + for (const invalid of [ { title: "x", scheduledStartAt: "2026-08-24T10:00:00", timezone: "UTC" }, + { title: "x", scheduledStartAt: "2026-08-24T10:00.5Z", timezone: "UTC" }, { title: "x", scheduledStartAt: "2026-08-24T10:00:00Z", timezone: "Mars/Base" }, { title: "x", From 592bdb08cd24097c175d498f13a56964bae0a3bc Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:03:27 +0800 Subject: [PATCH 14/33] fix(inspiration): align notification service contract --- .../tasks/08-24-inspiration-flow/check.jsonl | 1 + .../tasks/08-24-inspiration-flow/design.md | 26 +- .../08-24-inspiration-flow/implement.jsonl | 1 + .../tasks/08-24-inspiration-flow/implement.md | 8 + .trellis/tasks/08-24-inspiration-flow/prd.md | 16 +- .../08-24-inspiration-plugin/check.jsonl | 1 + .../tasks/08-24-inspiration-plugin/design.md | 22 +- .../08-24-inspiration-plugin/implement.jsonl | 1 + .../08-24-inspiration-plugin/implement.md | 9 + .../tasks/08-24-inspiration-plugin/prd.md | 21 +- .../research/plugin-patterns.md | 7 +- README.md | 2 +- docs/PLUGIN_API.md | 13 +- plugins/inspiration/README.md | 37 +- plugins/inspiration/echolog.plugin.json | 3 +- plugins/inspiration/src/flow-store.ts | 31 +- plugins/inspiration/src/flow.ts | 44 ++- plugins/inspiration/src/index.ts | 1 - plugins/inspiration/src/migrations.ts | 7 + plugins/inspiration/src/notifications.ts | 81 +++-- plugins/inspiration/src/schema.ts | 6 + plugins/inspiration/src/types.ts | 3 + tests/inspiration-capture.test.ts | 7 +- tests/inspiration-clients.test.ts | 4 + tests/inspiration-flow.test.ts | 218 +++++++++-- tests/inspiration-notification-host.test.ts | 338 ++++++++++++++++++ tests/inspiration.integration.ts | 44 ++- 27 files changed, 817 insertions(+), 135 deletions(-) create mode 100644 tests/inspiration-notification-host.test.ts diff --git a/.trellis/tasks/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/08-24-inspiration-flow/check.jsonl index 947916f..65e3eb8 100644 --- a/.trellis/tasks/08-24-inspiration-flow/check.jsonl +++ b/.trellis/tasks/08-24-inspiration-flow/check.jsonl @@ -1,3 +1,4 @@ {"file":".trellis/spec/backend/database-guidelines.md","reason":"Review transaction and dedupe correctness"} {"file":".trellis/spec/backend/quality-guidelines.md","reason":"Review job timeout/non-reentry and failure recovery"} {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review Flow state/lifecycle separation"} +{"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Review notification permission, channel outcomes, and privacy"} diff --git a/.trellis/tasks/08-24-inspiration-flow/design.md b/.trellis/tasks/08-24-inspiration-flow/design.md index 86690d8..74e1339 100644 --- a/.trellis/tasks/08-24-inspiration-flow/design.md +++ b/.trellis/tasks/08-24-inspiration-flow/design.md @@ -12,26 +12,20 @@ registry/build files, README, shared `types.ts`, or plugin `index.ts`. ## Notification boundary -The only host dependency is: +The only host dependency is the SDK-exported function: ```ts -export interface NotificationsSendService { - send( - input: { - title: string; - body: string; - dedupeKey: string; - data: { pluginId: "inspiration"; inspirationId: string; deliveryId: string }; - }, - signal?: AbortSignal - ): Promise<{ delivered: boolean; channel?: string }>; -} +type PluginNotificationSend = ( + request: { title: string; message: string }, + signal?: AbortSignal +) => Promise<PluginNotificationResult>; ``` -It is resolved lazily with -`context.service<NotificationsSendService>("notifications.send")`. Tests mock -this service. This branch does not implement or import the Core notifier and -does not widen the SDK. +It is resolved lazily with `context.service("notifications.send")`; the manifest +declares `notifications:send`. Inspiration passes no dedupe key or entity IDs to +Core. A delivery-owned JSONB projection stores bounded `mac`/`ntfy` channel +results. Overall success requires at least one `sent` channel. Tests use the +real PluginHost permission gate and function service in addition to unit mocks. ## Selection and atomicity diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/08-24-inspiration-flow/implement.jsonl index a905377..68f4eac 100644 --- a/.trellis/tasks/08-24-inspiration-flow/implement.jsonl +++ b/.trellis/tasks/08-24-inspiration-flow/implement.jsonl @@ -2,3 +2,4 @@ {"file":".trellis/spec/backend/quality-guidelines.md","reason":"Non-reentry, timeout, and durable job conventions"} {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Selector, delivery, notification, and API boundary design"} {"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Host job/service and plugin persistence research"} +{"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Implement official function-valued notification service"} diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.md b/.trellis/tasks/08-24-inspiration-flow/implement.md index 32b023f..7d58eb2 100644 --- a/.trellis/tasks/08-24-inspiration-flow/implement.md +++ b/.trellis/tasks/08-24-inspiration-flow/implement.md @@ -8,5 +8,13 @@ - [x] Test policies, dedupe/restart/failure/concurrency, abort, and store mocks. - [x] Run package typecheck and focused tests; report changed files only. +## Notification contract repair + +- [x] Replace local object service with SDK `PluginNotificationSend`. +- [x] Persist safe channel results with an additive migration and map delivery + success from channel statuses. +- [x] Update manifest, units/mocks, Web/CLI DTO fixtures, PostgreSQL integration, + and real PluginHost contract tests. + Validation: `pnpm --filter @echolog/plugin-inspiration typecheck` and `pnpm exec tsx --test tests/inspiration-flow.test.ts`. diff --git a/.trellis/tasks/08-24-inspiration-flow/prd.md b/.trellis/tasks/08-24-inspiration-flow/prd.md index 2cbe2e6..50653ae 100644 --- a/.trellis/tasks/08-24-inspiration-flow/prd.md +++ b/.trellis/tasks/08-24-inspiration-flow/prd.md @@ -21,8 +21,9 @@ inspiration lifecycle. - Outcomes are exactly `viewed`, `continued`, `kept`, `later`, `archived`. `later` only updates delivery snooze; `kept`/`archived` update the inspiration lifecycle atomically with the outcome using expected versions. -- Notifications use the local `notifications.send` interface and failures are - recorded without corrupting inspiration lifecycle or preventing later jobs. +- Notifications use the SDK-exported `PluginNotificationSend` function and + failures are recorded without corrupting inspiration lifecycle or preventing + later jobs. ## Acceptance Criteria @@ -35,3 +36,14 @@ inspiration lifecycle. - [x] `later` never changes inspiration `status`; concurrent stale outcomes return a conflict. - [x] Job behavior remains safe under Host non-reentry and timeout/abort. + +## Official notification contract acceptance + +- [x] Manifest declares `notifications:send`; missing permission is denied by a + real PluginHost with `PLUGIN_DEPENDENCY_MISSING` before service invocation. +- [x] The SDK `PluginNotificationSend` function receives exactly `{title, + message}` and never dedupe/entity metadata. +- [x] At least one `sent` channel finalizes delivery as sent; all-disabled or no + sent channel finalizes it as failed while retaining safe per-channel status. +- [x] Lazy service absence/failure remains ledgered and does not prevent Capture + or Core startup. diff --git a/.trellis/tasks/08-24-inspiration-plugin/check.jsonl b/.trellis/tasks/08-24-inspiration-plugin/check.jsonl index ef941c2..b9cb308 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/check.jsonl +++ b/.trellis/tasks/08-24-inspiration-plugin/check.jsonl @@ -2,3 +2,4 @@ {"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Web contribution quality review"} {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Full-stack contract consistency review"} {"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Compare implementation with established plugin patterns"} +{"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Verify exact named-service contract and permission gate"} diff --git a/.trellis/tasks/08-24-inspiration-plugin/design.md b/.trellis/tasks/08-24-inspiration-plugin/design.md index ec9d306..ba903d6 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/design.md +++ b/.trellis/tasks/08-24-inspiration-plugin/design.md @@ -48,9 +48,25 @@ optimistic/dedupe conflicts 409 with structured version context. The plugin is bundled and enabled by default for capture. The Flow send service is resolved only when a notification is attempted, so missing notification capability does not disable capture. A missing or failed service call finalizes -the delivery as failed and is visible in diagnostics/ledger. Once the separate -notifications worktree registers `notifications.send`, no plugin code change -should be required. +the delivery as failed and is visible in diagnostics/ledger. The official +notification capability is integrated at `8484b48`; its Core Host wiring owns +`notifications.send`, while Inspiration owns only lazy resolution and its +delivery ledger. Rollback is removal from the bundled registry/config; plugin-owned tables are left intact to preserve user data. + +## Official notification integration repair + +The authoritative baseline is original commit `29fe6c3`, cherry-picked on this +branch as `8484b48`. It exports `PluginNotificationSend` and +`PluginNotificationResult`, registers a function-valued `notifications.send` +service, and gates it with manifest permission `notifications:send`. + +Inspiration must not wrap or redefine that service. It lazily resolves the SDK +function and calls it with only `{title, message}`. Delivery `dedupeKey`, +`inspirationId`, and `deliveryId` never cross the Core service boundary. A new +append-only plugin migration stores the exact bounded per-channel result +projection in the delivery ledger. One or more `sent` channels means delivered; +all-disabled/all-failed/mixed-disabled-failed means not delivered. Thrown service +errors remain generic in the ledger so notification content cannot be reflected. diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl b/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl index fc2b112..83d4364 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl +++ b/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl @@ -2,3 +2,4 @@ {"file":".trellis/spec/frontend/index.md","reason":"Frontend pre-development and quality entry point"} {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Plugin spans persistence, HTTP, CLI, Web, jobs, and reports"} {"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Repository-specific bundled-plugin research"} +{"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Official notifications.send SDK, permission, and Host contract"} diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.md b/.trellis/tasks/08-24-inspiration-plugin/implement.md index 0c94682..a637a39 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/implement.md +++ b/.trellis/tasks/08-24-inspiration-plugin/implement.md @@ -14,5 +14,14 @@ - [x] Review/update specs if a reusable bundled-plugin pattern was learned. - [x] Commit coherent changes on `codex/inspiration-plugin` and record session. +## Notification contract repair iteration + +- [x] Cherry-pick official notification service commit without rewriting prior + Inspiration commits. +- [x] Replace local service object/request/result types with SDK function types. +- [x] Add manifest permission, per-channel ledger migration, and delivery logic. +- [x] Replace mocks and add real PluginHost contract integration tests. +- [x] Run independent check, full validation, append repair commit, and re-archive. + Rollback points: before root registry integration; before docs/Issue update; before commit. Never merge another branch. diff --git a/.trellis/tasks/08-24-inspiration-plugin/prd.md b/.trellis/tasks/08-24-inspiration-plugin/prd.md index da5c8c2..a94ed55 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/prd.md +++ b/.trellis/tasks/08-24-inspiration-plugin/prd.md @@ -26,8 +26,9 @@ product, with independently verifiable Capture, Flow, and client deliverables. - Allowed Flow actions are view, continue editing, keep, later, and archive. Task/schedule creation and scheduling are explicitly out of scope. - Flow notifications use `PluginContext.service("notifications.send")` through - the narrow local TypeScript contract documented in `design.md`. The Core - notifier and Host/SDK public contract are not copied into this branch. + the SDK-exported `PluginNotificationSend` contract. The Core notifier is not + copied; Host/SDK changes are limited to the audited official capability + commit `29fe6c3`, cherry-picked here as `8484b48`. - No screenshots, prompts, replies, or model reasoning are stored. - Web contributions load only when the plugin is ready. CLI commands remain HTTP-thin and preserve global `--json` raw-response/error behavior. @@ -56,3 +57,19 @@ product, with independently verifiable Capture, Flow, and client deliverables. The source request explicitly authorizes the full Trellis development flow, implementation, validation, documentation synchronization, and commit. + +## Notification Contract Repair + +- [x] Official notification capability commit `29fe6c3` is introduced with an + auditable cherry-pick and its SDK/Host tests remain intact. +- [x] Inspiration declares `notifications:send` and consumes the SDK-exported + function contract instead of a local object-shaped service. +- [x] Flow sends only `{ title, message }`; dedupe and entity identifiers remain + private delivery-ledger fields. +- [x] Per-channel `sent|disabled|failed` results are persisted in a bounded, + non-sensitive ledger projection; overall delivery succeeds only when at least + one channel reports `sent`. +- [x] Real PluginHost integration tests cover missing permission, function + invocation, channel combinations, and absence of a `.send()` assumption. +- [x] Full test, typecheck, build, diff check, independent review, repair commit, + and re-archive are complete without rewriting `3ab8946`. diff --git a/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md b/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md index cc42da7..7bce23b 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md +++ b/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md @@ -13,6 +13,7 @@ adapter at the composition point. - Web Shell calls contribution `load`, `loadLive`, `faces`, `renderFace`, and `handleAction`, and imports a module only when `/api/plugins` reports ready. -- No current Host service named `notifications.send` exists in this branch. - Inspiration therefore defines only a local generic interface and resolves the - service lazily; the separate notifications worktree owns Host wiring. +- At initial research time no Host service named `notifications.send` existed + on this branch. That finding is superseded by official commit `29fe6c3`, + cherry-picked here as `8484b48`: Inspiration now imports the SDK function + contract, resolves it lazily, and leaves Host wiring to Core. diff --git a/README.md b/README.md index 1a2838e..75676fb 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Plugin API v1 的通知 named service 由 [GitHub Issue #35](https://github.com/ - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 -- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/)。 +- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/08-24-inspiration-plugin/)。 插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。 diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 3226529..344b2f1 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -249,11 +249,14 @@ inspiration's kept/archived state. The plugin has no Schedule/Core-record API or table relationship. Flow resolves the named service `notifications.send` lazily through -`PluginContext.service()`. The plugin's local interface accepts a title, body, -dedupe key, and `{pluginId, inspirationId, deliveryId}` metadata and returns a -delivered flag plus optional channel. The notification service is Host-owned; -the plugin MUST NOT import or copy the Core notifier. Missing/failing delivery -is recorded in the plugin ledger while capture remains available. +`PluginContext.service()`, using the SDK-exported `PluginNotificationSend` +function. It sends only `{title, message}`. Inspiration-owned dedupe keys, +inspiration IDs, and delivery IDs never cross the Core service boundary. The +plugin persists the bounded `mac`/`ntfy` result projection in its private +delivery ledger and treats the delivery as sent only when at least one channel +reports `sent`. The notification service is Host-owned; the plugin MUST NOT +import or copy the Core notifier. Missing/failing delivery is recorded while +capture remains available. ## Compatibility policy diff --git a/plugins/inspiration/README.md b/plugins/inspiration/README.md index f84a9b3..b2a107e 100644 --- a/plugins/inspiration/README.md +++ b/plugins/inspiration/README.md @@ -47,24 +47,23 @@ reply, reasoning, or terminal content. Flow resolves exactly one host service lazily: ```ts -interface NotificationsSendService { - send( - input: { - title: string; - body: string; - dedupeKey: string; - data: { - pluginId: "inspiration"; - inspirationId: string; - deliveryId: string; - }; - }, - signal?: AbortSignal - ): Promise<{ delivered: boolean; channel?: string }>; -} +type PluginNotificationSend = ( + request: { title: string; message: string }, + signal?: AbortSignal +) => Promise<{ + channels: Record<"mac" | "ntfy", + | { status: "sent" } + | { status: "disabled" } + | { status: "failed"; error: string } + >; +}>; ``` -The service name is `notifications.send`. Host wiring belongs to the separate -notifications implementation. This package neither imports nor copies the Core -notifier. A missing or failed service is recorded as a failed Flow delivery; -Capture remains available. +The service name is `notifications.send` and the manifest declares the matching +`notifications:send` permission. The request contains only notification text; +dedupe keys and inspiration/delivery ids remain private to the plugin ledger. +At least one `sent` channel marks a delivery sent. Otherwise it is failed, with +the bounded per-channel result retained for diagnostics. Service resolution is +lazy, so a missing or failed notification capability is recorded as a failed +Flow delivery while Capture remains available. This package neither imports nor +copies the Core notifier. diff --git a/plugins/inspiration/echolog.plugin.json b/plugins/inspiration/echolog.plugin.json index 4d7142e..3539e18 100644 --- a/plugins/inspiration/echolog.plugin.json +++ b/plugins/inspiration/echolog.plugin.json @@ -16,7 +16,8 @@ "daily-report" ], "permissions": [ - "database:plugin" + "database:plugin", + "notifications:send" ], "requires": { "coreApi": "^1.0.0" diff --git a/plugins/inspiration/src/flow-store.ts b/plugins/inspiration/src/flow-store.ts index e7ad3bd..463c2b1 100644 --- a/plugins/inspiration/src/flow-store.ts +++ b/plugins/inspiration/src/flow-store.ts @@ -1,5 +1,6 @@ import { nanoid } from "nanoid"; import postgres from "postgres"; +import type { PluginNotificationResult } from "@echolog/plugin-sdk"; import { isQuietMinute, minuteOfLocalDay, @@ -60,6 +61,7 @@ type DeliveryRow = { snoozed_until: Date | string | null; outcome_at: Date | string | null; notification_channel: string | null; + notification_channels: PluginNotificationResult["channels"] | null; error: string | null; created_at: Date | string; updated_at: Date | string; @@ -94,6 +96,19 @@ export interface FlowOutcomeResult { inspiration: Inspiration; } +export type FlowNotificationFinalization = + | { + delivered: true; + channels: PluginNotificationResult["channels"]; + at: Date; + } + | { + delivered: false; + channels: PluginNotificationResult["channels"] | null; + error: string; + at: Date; + }; + function date(value: Date | string): Date { return value instanceof Date ? value : new Date(value); } @@ -150,6 +165,7 @@ function mapDelivery(row: DeliveryRow): FlowDelivery { snoozedUntil: nullableDate(row.snoozed_until), outcomeAt: nullableDate(row.outcome_at), notificationChannel: row.notification_channel, + notificationChannels: row.notification_channels, error: row.error, createdAt: date(row.created_at), updatedAt: date(row.updated_at), @@ -452,15 +468,15 @@ export class FlowStore { async finalizeNotification( deliveryId: string, expectedVersion: number, - result: - | { delivered: true; channel: string | null; at: Date } - | { delivered: false; error: string; at: Date } + result: FlowNotificationFinalization ): Promise<FlowDelivery> { const rows = result.delivered ? await this.sql<DeliveryRow[]>` UPDATE inspiration_flow_deliveries SET status = 'sent', notified_at = ${result.at}, - notification_channel = ${result.channel}, error = NULL, + notification_channel = NULL, + notification_channels = ${this.sql.json(result.channels)}, + error = NULL, version = version + 1, updated_at = ${result.at} WHERE id = ${deliveryId} AND version = ${expectedVersion} AND status = 'reserved' @@ -468,7 +484,12 @@ export class FlowStore { ` : await this.sql<DeliveryRow[]>` UPDATE inspiration_flow_deliveries - SET status = 'failed', error = ${result.error}, + SET status = 'failed', + notification_channel = NULL, + notification_channels = ${result.channels === null + ? null + : this.sql.json(result.channels)}, + error = ${result.error}, version = version + 1, updated_at = ${result.at} WHERE id = ${deliveryId} AND version = ${expectedVersion} AND status = 'reserved' diff --git a/plugins/inspiration/src/flow.ts b/plugins/inspiration/src/flow.ts index c8e9d3a..ff14ef4 100644 --- a/plugins/inspiration/src/flow.ts +++ b/plugins/inspiration/src/flow.ts @@ -1,6 +1,11 @@ import { nanoid } from "nanoid"; -import type { PluginJob } from "@echolog/plugin-sdk"; +import { + PluginError, + type PluginJob, + type PluginNotificationResult, +} from "@echolog/plugin-sdk"; import type { + FlowNotificationFinalization, FlowOutcomeResult, FlowReserveResult, } from "./flow-store.js"; @@ -35,6 +40,29 @@ function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } +function notificationFailureMessage(error: unknown): string { + return error instanceof PluginError && + error.code === "PLUGIN_DEPENDENCY_MISSING" + ? "notifications.send unavailable (PLUGIN_DEPENDENCY_MISSING)" + : "notifications.send failed"; +} + +export function notificationWasDelivered( + result: PluginNotificationResult +): boolean { + return Object.values(result.channels).some( + (channel) => channel.status === "sent" + ); +} + +function noDeliveryMessage(result: PluginNotificationResult): string { + return Object.values(result.channels).every( + (channel) => channel.status === "disabled" + ) + ? "notifications.send has no enabled channels" + : "notifications.send failed on all enabled channels"; +} + export interface FlowPersistence { getSettings(): Promise<FlowSettings>; updateSettings(input: FlowSettingsUpdate): Promise<FlowSettings | null>; @@ -47,9 +75,7 @@ export interface FlowPersistence { finalizeNotification( deliveryId: string, expectedVersion: number, - result: - | { delivered: true; channel: string | null; at: Date } - | { delivered: false; error: string; at: Date } + result: FlowNotificationFinalization ): Promise<FlowDelivery>; listDeliveries(limit?: number, before?: Date): Promise<FlowDelivery[]>; applyOutcome( @@ -141,10 +167,11 @@ export class FlowService { candidate.delivery.version, { delivered: false, + channels: null, // Do not persist exception text: provider errors may echo request // bodies. The ledger records a stable diagnostic without retaining // notification content, prompts, or replies. - error: "notifications.send failed", + error: notificationFailureMessage(error), at: this.clock(), } ); @@ -154,15 +181,16 @@ export class FlowService { candidate.delivery = await this.store.finalizeNotification( candidate.delivery.id, candidate.delivery.version, - notification.delivered + notificationWasDelivered(notification) ? { delivered: true, - channel: notification.channel ?? null, + channels: notification.channels, at: this.clock(), } : { delivered: false, - error: "notifications.send reported an undelivered notification", + channels: notification.channels, + error: noDeliveryMessage(notification), at: this.clock(), } ); diff --git a/plugins/inspiration/src/index.ts b/plugins/inspiration/src/index.ts index 019fac3..c1bd652 100644 --- a/plugins/inspiration/src/index.ts +++ b/plugins/inspiration/src/index.ts @@ -82,4 +82,3 @@ export const inspirationPlugin: PluginDefinition = { export default inspirationPlugin; export { migrations } from "./migrations.js"; -export type { NotificationsSendService } from "./notifications.js"; diff --git a/plugins/inspiration/src/migrations.ts b/plugins/inspiration/src/migrations.ts index aa0ed01..9927a03 100644 --- a/plugins/inspiration/src/migrations.ts +++ b/plugins/inspiration/src/migrations.ts @@ -131,4 +131,11 @@ export const migrations: PluginMigration[] = [ CHECK (attempts >= 1); `, }, + { + name: "005_inspiration_flow_notification_channels", + sql: ` + ALTER TABLE inspiration_flow_deliveries + ADD COLUMN IF NOT EXISTS notification_channels JSONB; + `, + }, ]; diff --git a/plugins/inspiration/src/notifications.ts b/plugins/inspiration/src/notifications.ts index 3aeb4e7..ec6f5b9 100644 --- a/plugins/inspiration/src/notifications.ts +++ b/plugins/inspiration/src/notifications.ts @@ -1,54 +1,69 @@ -import type { PluginContext } from "@echolog/plugin-sdk"; +import type { + PluginContext, + PluginNotificationChannelResult, + PluginNotificationResult, + PluginNotificationSend, +} from "@echolog/plugin-sdk"; import type { FlowCandidate } from "./types.js"; -export interface NotificationsSendInput { - title: string; - body: string; - dedupeKey: string; - data: { - pluginId: "inspiration"; - inspirationId: string; - deliveryId: string; - }; -} +const MAX_NOTIFICATION_CHANNEL_ERROR_LENGTH = 160; -export interface NotificationsSendResult { - delivered: boolean; - channel?: string; -} +export type NotificationsSendProvider = () => PluginNotificationSend; -export interface NotificationsSendService { - send( - input: NotificationsSendInput, - signal?: AbortSignal - ): Promise<NotificationsSendResult>; +function projectChannelResult( + channel: "mac" | "ntfy", + value: unknown +): PluginNotificationChannelResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`notifications.send returned an invalid ${channel} result`); + } + const result = value as Record<string, unknown>; + if (result.status === "sent") return { status: "sent" }; + if (result.status === "disabled") return { status: "disabled" }; + if (result.status === "failed" && typeof result.error === "string") { + return { + status: "failed", + error: result.error.slice(0, MAX_NOTIFICATION_CHANNEL_ERROR_LENGTH), + }; + } + throw new Error(`notifications.send returned an invalid ${channel} result`); } -export type NotificationsSendProvider = () => NotificationsSendService; +export function projectNotificationResult( + result: PluginNotificationResult +): PluginNotificationResult { + const channels = (result as unknown as { channels?: unknown }).channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + throw new Error("notifications.send returned invalid channels"); + } + const source = channels as Record<string, unknown>; + return { + channels: { + mac: projectChannelResult("mac", source.mac), + ntfy: projectChannelResult("ntfy", source.ntfy), + }, + }; +} export function notificationsSendProvider( context: PluginContext ): NotificationsSendProvider { // Service resolution must remain lazy: capture and organization continue to // work when the independently shipped notification capability is absent. - return () => - context.service<NotificationsSendService>("notifications.send"); + return () => context.service("notifications.send"); } export function sendFlowNotification( provider: NotificationsSendProvider, candidate: FlowCandidate, signal?: AbortSignal -): Promise<NotificationsSendResult> { +): Promise<PluginNotificationResult> { signal?.throwIfAborted(); - return provider().send({ - title: "Inspiration", - body: candidate.inspiration.content, - dedupeKey: candidate.delivery.dedupeKey, - data: { - pluginId: "inspiration", - inspirationId: candidate.inspiration.id, - deliveryId: candidate.delivery.id, + return provider()( + { + title: "Inspiration", + message: candidate.inspiration.content, }, - }, signal); + signal + ).then(projectNotificationResult); } diff --git a/plugins/inspiration/src/schema.ts b/plugins/inspiration/src/schema.ts index dac1d4a..0177db8 100644 --- a/plugins/inspiration/src/schema.ts +++ b/plugins/inspiration/src/schema.ts @@ -4,11 +4,15 @@ import { check, index, integer, + jsonb, pgTable, text, timestamp, uniqueIndex, } from "drizzle-orm/pg-core"; +import type { + PluginNotificationResult, +} from "@echolog/plugin-sdk"; import type { FlowDeliveryStatus, FlowOutcome, @@ -137,6 +141,8 @@ export const inspirationFlowDeliveries = pgTable( snoozedUntil: timestamp("snoozed_until", { withTimezone: true }), outcomeAt: timestamp("outcome_at", { withTimezone: true }), notificationChannel: text("notification_channel"), + notificationChannels: jsonb("notification_channels") + .$type<PluginNotificationResult["channels"]>(), error: text("error"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() diff --git a/plugins/inspiration/src/types.ts b/plugins/inspiration/src/types.ts index 54afb38..5db0d6e 100644 --- a/plugins/inspiration/src/types.ts +++ b/plugins/inspiration/src/types.ts @@ -1,3 +1,5 @@ +import type { PluginNotificationResult } from "@echolog/plugin-sdk"; + export type InspirationStatus = "inbox" | "kept" | "archived"; export interface Inspiration { @@ -91,6 +93,7 @@ export interface FlowDelivery { snoozedUntil: Date | null; outcomeAt: Date | null; notificationChannel: string | null; + notificationChannels: PluginNotificationResult["channels"] | null; error: string | null; createdAt: Date; updatedAt: Date; diff --git a/tests/inspiration-capture.test.ts b/tests/inspiration-capture.test.ts index 929dc01..f89a2b2 100644 --- a/tests/inspiration-capture.test.ts +++ b/tests/inspiration-capture.test.ts @@ -192,18 +192,23 @@ async function call( test("manifest and migrations define one private standalone plugin schema", () => { assert.equal(manifest.id, "inspiration"); - assert.deepEqual(manifest.permissions, ["database:plugin"]); + assert.deepEqual(manifest.permissions, [ + "database:plugin", + "notifications:send", + ]); assert.deepEqual(migrations.map((migration) => migration.name), [ "001_inspirations", "002_inspiration_flow_settings", "003_inspiration_flow_deliveries", "004_inspiration_flow_delivery_attempts", + "005_inspiration_flow_notification_channels", ]); const sql = migrations.map((migration) => migration.sql).join("\n"); assert.match(sql, /CREATE TABLE IF NOT EXISTS inspirations/); assert.match(sql, /CREATE TABLE IF NOT EXISTS inspiration_flow_settings/); assert.match(sql, /CREATE TABLE IF NOT EXISTS inspiration_flow_deliveries/); assert.match(sql, /dedupe_key[\s\S]*CREATE UNIQUE INDEX/); + assert.match(sql, /ADD COLUMN IF NOT EXISTS notification_channels JSONB/); assert.match(sql, /inspiration_id TEXT NOT NULL REFERENCES inspirations\(id\)/); assert.match(sql, /CHECK \(\(status = 'archived'\) = \(archived_at IS NOT NULL\)\)/); assert.doesNotMatch(sql, /REFERENCES\s+(records|tasks|schedule)/i); diff --git a/tests/inspiration-clients.test.ts b/tests/inspiration-clients.test.ts index 3fce375..5334767 100644 --- a/tests/inspiration-clients.test.ts +++ b/tests/inspiration-clients.test.ts @@ -259,6 +259,10 @@ test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow poli snoozedUntil: null, outcomeAt: null, notificationChannel: null, + notificationChannels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, error: null, createdAt: "2026-08-24T05:00:00.000Z", updatedAt: "2026-08-24T05:00:00.000Z", diff --git a/tests/inspiration-flow.test.ts b/tests/inspiration-flow.test.ts index 60dedf9..3c21a57 100644 --- a/tests/inspiration-flow.test.ts +++ b/tests/inspiration-flow.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { PluginHttpRequest } from "@echolog/plugin-sdk"; +import type { + PluginHttpRequest, + PluginNotificationResult, +} from "@echolog/plugin-sdk"; import { createFlowRoutes, validateOutcome, validateSettingsUpdate } from "../plugins/inspiration/src/flow-routes.js"; import { FlowStoreError, type FlowOutcomeResult, type FlowReserveResult } from "../plugins/inspiration/src/flow-store.js"; import { @@ -76,6 +79,7 @@ function delivery(overrides: Partial<FlowDelivery> = {}): FlowDelivery { snoozedUntil: null, outcomeAt: null, notificationChannel: null, + notificationChannels: null, error: null, createdAt: NOW, updatedAt: NOW, @@ -134,9 +138,14 @@ function persistence( version: 2, status: "sent", notifiedAt: result.at, - notificationChannel: result.channel, + notificationChannels: result.channels, }) - : delivery({ version: 2, status: "failed", error: result.error }); + : delivery({ + version: 2, + status: "failed", + notificationChannels: result.channels, + error: result.error, + }); }, async listDeliveries() { return []; @@ -295,9 +304,10 @@ test("scheduled job is bounded and forwards the Host abort signal", async () => assert.deepEqual(observed, [controller.signal]); }); -test("service sends the narrow notification contract and finalizes the ledger", async () => { +test("service calls the function-valued notification contract with title and message only", async () => { const finalized: unknown[] = []; - const sent: unknown[] = []; + const sent: Array<{ input: unknown; signal: AbortSignal | undefined }> = []; + const controller = new AbortController(); const store = persistence({ async finalizeNotification(...args) { finalized.push(args); @@ -306,25 +316,25 @@ test("service sends the narrow notification contract and finalizes the ledger", }); const service = new FlowService( store, - () => ({ - async send(input) { - sent.push(input); - return { delivered: true, channel: "local" }; - }, - }), + () => async (input, signal) => { + sent.push({ input, signal }); + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, () => NOW ); - const result = await service.nextManual("request-a"); + const result = await service.nextManual("request-a", controller.signal); assert.equal(result.candidate?.delivery.status, "sent"); assert.deepEqual(sent, [{ - title: "Inspiration", - body: "Build a deterministic inspiration flow", - dedupeKey: "manual:request-a", - data: { - pluginId: "inspiration", - inspirationId: "idea-a", - deliveryId: "delivery-a", + input: { + title: "Inspiration", + message: "Build a deterministic inspiration flow", }, + signal: controller.signal, }]); assert.equal(finalized.length, 1); assert.deepEqual((finalized[0] as unknown[]).slice(0, 2), ["delivery-a", 1]); @@ -345,12 +355,15 @@ test("reserved duplicate resumes after restart but sent duplicate is not re-sent return delivery({ version: 2, status: "sent" }); }, }); - const service = new FlowService(store, () => ({ - async send() { + const service = new FlowService(store, () => async () => { sends += 1; - return { delivered: true }; - }, - }), () => NOW); + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, () => NOW); await service.nextManual("same-request"); await service.nextManual("same-request"); @@ -364,21 +377,124 @@ test("notification failures are recorded without leaking provider error text", a finalization = result; return delivery({ version: 2, status: "failed", error: "notifications.send failed" }); }, - }), () => ({ - async send() { + }), () => async () => { throw new Error("secret provider response and echoed notification body"); - }, - }), () => NOW); + }, () => NOW); const result = await service.nextManual("failed-request"); assert.equal(result.candidate?.delivery.status, "failed"); assert.deepEqual(finalization, { delivered: false, + channels: null, error: "notifications.send failed", at: NOW, }); }); +test("channel results require at least one sent channel and remain ledgered", async () => { + const cases: Array<{ + name: string; + result: PluginNotificationResult; + status: FlowDelivery["status"]; + error: string | null; + }> = [ + { + name: "sent plus failed", + result: { + channels: { + mac: { status: "sent" }, + ntfy: { status: "failed", error: "ntfy notification failed" }, + }, + }, + status: "sent", + error: null, + }, + { + name: "all disabled", + result: { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "disabled" }, + }, + }, + status: "failed", + error: "notifications.send has no enabled channels", + }, + { + name: "disabled plus failed", + result: { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "ntfy notification timed out" }, + }, + }, + status: "failed", + error: "notifications.send failed on all enabled channels", + }, + ]; + + for (const item of cases) { + let finalization: Parameters<FlowPersistence["finalizeNotification"]>[2] | undefined; + const service = new FlowService(persistence({ + async finalizeNotification(_id, _version, result) { + finalization = result; + return delivery({ + version: 2, + status: result.delivered ? "sent" : "failed", + notificationChannels: result.channels, + error: result.delivered ? null : result.error, + }); + }, + }), () => async () => item.result, () => NOW); + + const result = await service.nextManual(item.name); + assert.equal(result.candidate?.delivery.status, item.status, item.name); + assert.deepEqual(finalization?.channels, item.result.channels, item.name); + assert.equal( + finalization && !finalization.delivered ? finalization.error : null, + item.error, + item.name + ); + } +}); + +test("notification ledger projects only bounded official channel fields", async () => { + let finalization: Parameters<FlowPersistence["finalizeNotification"]>[2] | undefined; + const oversizedError = "x".repeat(300); + const providerResult = { + channels: { + mac: { status: "disabled", endpoint: "must-not-be-persisted" }, + ntfy: { + status: "failed", + error: oversizedError, + responseBody: "must-not-be-persisted", + }, + unexpected: { status: "sent", secret: "must-not-be-persisted" }, + }, + data: { deliveryId: "must-not-be-persisted" }, + } as unknown as PluginNotificationResult; + const service = new FlowService(persistence({ + async finalizeNotification(_id, _version, result) { + finalization = result; + return delivery({ + version: 2, + status: result.delivered ? "sent" : "failed", + notificationChannels: result.channels, + error: result.delivered ? null : result.error, + }); + }, + }), () => async () => providerResult, () => NOW); + + const result = await service.nextManual("bounded-projection"); + + assert.equal(result.candidate?.delivery.status, "failed"); + assert.deepEqual(finalization?.channels, { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "x".repeat(160) }, + }); + assert.equal(JSON.stringify(finalization).includes("must-not-be-persisted"), false); +}); + test("abort leaves a durable reservation for a later restart", async () => { let finalized = false; const controller = new AbortController(); @@ -391,11 +507,9 @@ test("abort leaves a durable reservation for a later restart", async () => { finalized = true; return delivery(); }, - }), () => ({ - async send() { + }), () => async () => { assert.fail("notification must not be attempted after abort"); - }, - }), () => NOW); + }, () => NOW); await assert.rejects( service.nextManual("aborted", controller.signal), @@ -411,7 +525,12 @@ test("later calculates delivery snooze without requesting a lifecycle mutation", call = args; return outcomeResult(); }, - }), () => ({ async send() { return { delivered: true }; } }), () => NOW); + }), () => async () => ({ + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }), () => NOW); await service.applyOutcome("delivery-a", { expectedDeliveryVersion: 2, @@ -497,6 +616,39 @@ test("settings validation normalizes tags consistently with Capture", () => { } }); +test("delivery API DTO retains the projected channel ledger", async () => { + const projected = { + mac: { status: "sent" as const }, + ntfy: { status: "failed" as const, error: "ntfy notification failed" }, + }; + const stored = delivery({ + status: "sent", + notificationChannels: projected, + }); + const service = { + async listDeliveries(limit: number, before?: Date) { + assert.equal(limit, 10); + assert.equal(before?.toISOString(), "2026-08-24T13:00:00.000Z"); + return [stored]; + }, + } as unknown as FlowService; + const route = createFlowRoutes(() => service).find( + (item) => item.method === "GET" && item.path.endsWith("/deliveries") + )!; + const result = await route.handler({ + params: {}, + query: { limit: "10", before: "2026-08-24T13:00:00.000Z" }, + body: null, + headers: {}, + }, new AbortController().signal); + + assert.deepEqual(result, { deliveries: [stored] }); + assert.deepEqual( + (result as { deliveries: FlowDelivery[] }).deliveries[0]?.notificationChannels, + projected + ); +}); + test("Flow exposes only canonical inspiration plugin routes", () => { const paths = createFlowRoutes(() => ({} as FlowService)).map((route) => route.path); assert.deepEqual(paths, [ diff --git a/tests/inspiration-notification-host.test.ts b/tests/inspiration-notification-host.test.ts new file mode 100644 index 0000000..34e6a07 --- /dev/null +++ b/tests/inspiration-notification-host.test.ts @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + PluginError, + type PluginDefinition, + type PluginLogger, + type PluginManifest, + type PluginNotificationResult, + type PluginNotificationSend, +} from "@echolog/plugin-sdk"; +import inspirationManifestJson from "../plugins/inspiration/echolog.plugin.json" with { type: "json" }; +import type { + FlowNotificationFinalization, + FlowReserveResult, +} from "../plugins/inspiration/src/flow-store.js"; +import { + FlowService, + type FlowPersistence, +} from "../plugins/inspiration/src/flow.js"; +import { notificationsSendProvider } from "../plugins/inspiration/src/notifications.js"; +import type { + FlowCandidate, + FlowDelivery, + FlowSettings, + Inspiration, +} from "../plugins/inspiration/src/types.js"; +import { PluginHost } from "../src/core/plugins/host.js"; + +const NOW = new Date("2026-08-24T12:00:00.000Z"); +const manifest = inspirationManifestJson as PluginManifest; + +const logger: PluginLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +function host( + definition: PluginDefinition, + services: Record<string, unknown> = {} +): PluginHost { + return new PluginHost({ + definitions: [definition], + logger, + migrationRunner: async () => {}, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + services, + }); +} + +function inspiration(): Inspiration { + return { + id: "idea-host", + version: 2, + content: "A Host-integrated inspiration", + tags: [], + project: null, + status: "inbox", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + lastSurfacedAt: NOW, + }; +} + +function delivery(overrides: Partial<FlowDelivery> = {}): FlowDelivery { + return { + id: "delivery-host", + version: 1, + attempts: 1, + inspirationId: "idea-host", + source: "manual", + dedupeKey: "manual:host", + status: "reserved", + outcome: null, + surfacedAt: NOW, + notifiedAt: null, + snoozedUntil: null, + outcomeAt: null, + notificationChannel: null, + notificationChannels: null, + error: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function candidate(): FlowCandidate { + return { + inspiration: inspiration(), + delivery: delivery(), + explanation: ["selection:never-surfaced-first"], + duplicate: false, + }; +} + +function settings(): FlowSettings { + return { + id: "default", + version: 1, + enabled: true, + intervalMinutes: 60, + quietStartMinute: 0, + quietEndMinute: 0, + cooldownMinutes: 0, + dailyLimit: 3, + defaultSnoozeMinutes: 120, + statuses: ["inbox", "kept"], + tags: [], + projects: [], + updatedAt: NOW, + }; +} + +function persistence( + onFinalize: (result: FlowNotificationFinalization) => void +): FlowPersistence { + return { + async getSettings() { + return settings(); + }, + async updateSettings() { + return settings(); + }, + async reserveNext(): Promise<FlowReserveResult> { + const selected = candidate(); + return { + candidate: selected, + explanation: selected.explanation, + shouldNotify: true, + }; + }, + async finalizeNotification(_id, _version, result) { + onFinalize(result); + return delivery({ + version: 2, + status: result.delivered ? "sent" : "failed", + notifiedAt: result.delivered ? result.at : null, + notificationChannels: result.channels, + error: result.delivered ? null : result.error, + }); + }, + async listDeliveries() { + return []; + }, + async applyOutcome() { + return { delivery: delivery(), inspiration: inspiration() }; + }, + async getDailySummary() { + return { captured: 0, surfaced: 0, outcomes: {} }; + }, + }; +} + +test("real PluginHost denies the actual Inspiration provider without permission", async () => { + let serviceCalls = 0; + let denied: unknown; + const send: PluginNotificationSend = async () => { + serviceCalls += 1; + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "sent" }, + }, + }; + }; + const deniedManifest: PluginManifest = { + ...manifest, + permissions: manifest.permissions.filter( + (permission) => permission !== "notifications:send" + ), + }; + const pluginHost = host({ + manifest: deniedManifest, + defaultEnabled: true, + async start(context) { + try { + await notificationsSendProvider(context)()({ + title: "Inspiration", + message: "must not leave the Host", + }); + } catch (error) { + denied = error; + throw error; + } + }, + }, { "notifications.send": send }); + + await pluginHost.initialize(); + + assert.ok(denied instanceof PluginError); + assert.equal(denied.code, "PLUGIN_DEPENDENCY_MISSING"); + assert.equal(denied.statusCode, 403); + assert.equal(denied.pluginId, "inspiration"); + assert.equal(pluginHost.list()[0]?.state, "degraded"); + assert.equal(serviceCalls, 0); +}); + +test("actual Inspiration provider passes a bare Host function only title/message", async () => { + const requests: unknown[] = []; + const finalizations: FlowNotificationFinalization[] = []; + const send: PluginNotificationSend = async (request) => { + requests.push(request); + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }; + assert.equal("send" in send, false); + const pluginHost = host({ + manifest, + defaultEnabled: true, + async start(context) { + const flow = new FlowService( + persistence((result) => finalizations.push(result)), + notificationsSendProvider(context), + () => NOW + ); + await flow.nextManual("host-contract"); + }, + }, { "notifications.send": send }); + + await pluginHost.initialize(); + + assert.equal(pluginHost.list()[0]?.state, "ready"); + assert.deepEqual(requests, [{ + title: "Inspiration", + message: "A Host-integrated inspiration", + }]); + assert.deepEqual(finalizations, [{ + delivered: true, + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + at: NOW, + }]); +}); + +test("real Host channel results drive Inspiration delivery status", async () => { + const cases: Array<{ + name: string; + result: PluginNotificationResult; + delivered: boolean; + }> = [ + { + name: "failed plus sent", + result: { + channels: { + mac: { status: "failed", error: "mac notification failed" }, + ntfy: { status: "sent" }, + }, + }, + delivered: true, + }, + { + name: "all disabled", + result: { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "disabled" }, + }, + }, + delivered: false, + }, + { + name: "failed plus disabled", + result: { + channels: { + mac: { status: "failed", error: "mac notification failed" }, + ntfy: { status: "disabled" }, + }, + }, + delivered: false, + }, + ]; + + for (const item of cases) { + let finalized: FlowNotificationFinalization | undefined; + const send: PluginNotificationSend = async () => item.result; + const pluginHost = host({ + manifest, + defaultEnabled: true, + async start(context) { + const flow = new FlowService( + persistence((result) => { + finalized = result; + }), + notificationsSendProvider(context), + () => NOW + ); + await flow.nextManual(item.name); + }, + }, { "notifications.send": send }); + + await pluginHost.initialize(); + + assert.equal(pluginHost.list()[0]?.state, "ready", item.name); + assert.equal(finalized?.delivered, item.delivered, item.name); + assert.deepEqual(finalized?.channels, item.result.channels, item.name); + } +}); + +test("lazy missing service leaves Host ready until Flow records a generic failure", async () => { + let provider: ReturnType<typeof notificationsSendProvider> | undefined; + const finalizations: FlowNotificationFinalization[] = []; + const pluginHost = host({ + manifest, + defaultEnabled: true, + register(context) { + provider = notificationsSendProvider(context); + }, + }); + + await pluginHost.initialize(); + assert.equal(pluginHost.list()[0]?.state, "ready"); + assert.ok(provider); + + const flow = new FlowService( + persistence((result) => finalizations.push(result)), + provider, + () => NOW + ); + const result = await flow.nextManual("missing-service"); + + assert.equal(result.candidate?.delivery.status, "failed"); + assert.deepEqual(finalizations, [{ + delivered: false, + channels: null, + error: "notifications.send failed", + at: NOW, + }]); + assert.equal(pluginHost.list()[0]?.state, "ready"); +}); diff --git a/tests/inspiration.integration.ts b/tests/inspiration.integration.ts index 61fb73e..1e3e980 100644 --- a/tests/inspiration.integration.ts +++ b/tests/inspiration.integration.ts @@ -126,10 +126,28 @@ test( const sent = await flowA.finalizeNotification( owner.candidate.delivery.id, owner.candidate.delivery.version, - { delivered: true, channel: "integration", at: manualNow } + { + delivered: true, + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + at: manualNow, + } ); assert.equal(sent.status, "sent"); assert.equal(sent.attempts, 1); + assert.deepEqual(sent.notificationChannels, { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }); + const sentFromLedger = (await flowB.listDeliveries()).find( + (item) => item.id === sent.id + ); + assert.deepEqual(sentFromLedger?.notificationChannels, { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }); const statusBeforeLater = owner.candidate.inspiration.status; const later = await flowA.applyOutcome( @@ -175,10 +193,32 @@ test( const finalizedRecovery = await flowB.finalizeNotification( recovered.candidate.delivery.id, recovered.candidate.delivery.version, - { delivered: false, error: "notifications.send failed", at: afterBoundary } + { + delivered: false, + channels: { + mac: { status: "disabled" }, + ntfy: { + status: "failed", + error: "ntfy notification timed out", + }, + }, + error: "notifications.send failed on all enabled channels", + at: afterBoundary, + } ); assert.equal(finalizedRecovery.status, "failed"); assert.equal(finalizedRecovery.attempts, 2); + assert.deepEqual(finalizedRecovery.notificationChannels, { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "ntfy notification timed out" }, + }); + const failureFromLedger = (await flowA.listDeliveries()).find( + (item) => item.id === finalizedRecovery.id + ); + assert.deepEqual(failureFromLedger?.notificationChannels, { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "ntfy notification timed out" }, + }); const retryAfterFailure = await flowA.reserveNext( "scheduled", From fa3a50741a0ae77b26b07152a8655117a511b948 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:03:38 +0800 Subject: [PATCH 15/33] chore(trellis): archive schedule review fixes --- .../2026-08}/08-24-schedule-review-fixes/check.jsonl | 0 .../2026-08}/08-24-schedule-review-fixes/design.md | 0 .../2026-08}/08-24-schedule-review-fixes/implement.jsonl | 0 .../2026-08}/08-24-schedule-review-fixes/implement.md | 0 .../2026-08}/08-24-schedule-review-fixes/prd.md | 2 +- .../2026-08}/08-24-schedule-review-fixes/task.json | 6 +++--- 6 files changed, 4 insertions(+), 4 deletions(-) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-review-fixes/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-review-fixes/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-review-fixes/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-review-fixes/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-review-fixes/prd.md (95%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-review-fixes/task.json (81%) diff --git a/.trellis/tasks/08-24-schedule-review-fixes/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-review-fixes/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/check.jsonl diff --git a/.trellis/tasks/08-24-schedule-review-fixes/design.md b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/design.md similarity index 100% rename from .trellis/tasks/08-24-schedule-review-fixes/design.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/design.md diff --git a/.trellis/tasks/08-24-schedule-review-fixes/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-review-fixes/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/implement.jsonl diff --git a/.trellis/tasks/08-24-schedule-review-fixes/implement.md b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/implement.md similarity index 100% rename from .trellis/tasks/08-24-schedule-review-fixes/implement.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/implement.md diff --git a/.trellis/tasks/08-24-schedule-review-fixes/prd.md b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/prd.md similarity index 95% rename from .trellis/tasks/08-24-schedule-review-fixes/prd.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/prd.md index e9317b9..a331127 100644 --- a/.trellis/tasks/08-24-schedule-review-fixes/prd.md +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/prd.md @@ -29,4 +29,4 @@ service boundary. the existing integration suite remains green. - [x] Focused tests, PostgreSQL integration, `pnpm test`, `pnpm typecheck`, and `pnpm build` pass. -- [ ] Fixes are committed on `codex/schedule-plugin` after final diff review. +- [x] Fixes are committed on `codex/schedule-plugin` after final diff review. diff --git a/.trellis/tasks/08-24-schedule-review-fixes/task.json b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/task.json similarity index 81% rename from .trellis/tasks/08-24-schedule-review-fixes/task.json rename to .trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/task.json index dfd00dc..0f61ac0 100644 --- a/.trellis/tasks/08-24-schedule-review-fixes/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-review-fixes/task.json @@ -3,7 +3,7 @@ "name": "schedule-review-fixes", "title": "Fix Schedule local review findings", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "cross-layer", "package": null, @@ -11,11 +11,11 @@ "creator": "codex", "assignee": "codex", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/schedule-plugin", "base_branch": "main", "worktree_path": null, - "commit": null, + "commit": "ae65748fb4d96b77850b00807c2975f483283e05", "pr_url": null, "subtasks": [], "children": [], From 5fb4befb72780d0e975b3a5cf34e69797d2ce40e Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:03:55 +0800 Subject: [PATCH 16/33] chore(task): rearchive inspiration after notification fix --- .../08-24-inspiration-capture/check.jsonl | 0 .../08-24-inspiration-capture/design.md | 0 .../08-24-inspiration-capture/implement.jsonl | 0 .../08-24-inspiration-capture/implement.md | 0 .../08-24-inspiration-capture/prd.md | 0 .../08-24-inspiration-capture/task.json | 4 ++-- .../08-24-inspiration-clients/check.jsonl | 0 .../08-24-inspiration-clients/design.md | 0 .../08-24-inspiration-clients/implement.jsonl | 0 .../08-24-inspiration-clients/implement.md | 0 .../08-24-inspiration-clients/prd.md | 0 .../08-24-inspiration-clients/task.json | 4 ++-- .../08-24-inspiration-flow/check.jsonl | 0 .../08-24-inspiration-flow}/08-24-inspiration-flow/design.md | 0 .../08-24-inspiration-flow/implement.jsonl | 0 .../08-24-inspiration-flow/implement.md | 0 .../08-24-inspiration-flow}/08-24-inspiration-flow/prd.md | 0 .../08-24-inspiration-flow}/08-24-inspiration-flow/task.json | 4 ++-- .../2026-08}/08-24-inspiration-plugin/check.jsonl | 0 .../{ => archive/2026-08}/08-24-inspiration-plugin/design.md | 0 .../2026-08}/08-24-inspiration-plugin/implement.jsonl | 0 .../2026-08}/08-24-inspiration-plugin/implement.md | 0 .../{ => archive/2026-08}/08-24-inspiration-plugin/prd.md | 0 .../08-24-inspiration-plugin/research/plugin-patterns.md | 0 .../{ => archive/2026-08}/08-24-inspiration-plugin/task.json | 4 ++-- README.md | 2 +- 26 files changed, 9 insertions(+), 9 deletions(-) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-capture}/08-24-inspiration-capture/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-capture}/08-24-inspiration-capture/design.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-capture}/08-24-inspiration-capture/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-capture}/08-24-inspiration-capture/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-capture}/08-24-inspiration-capture/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-capture}/08-24-inspiration-capture/task.json (91%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-clients}/08-24-inspiration-clients/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-clients}/08-24-inspiration-clients/design.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-clients}/08-24-inspiration-clients/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-clients}/08-24-inspiration-clients/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-clients}/08-24-inspiration-clients/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-clients}/08-24-inspiration-clients/task.json (91%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-flow}/08-24-inspiration-flow/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-flow}/08-24-inspiration-flow/design.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-flow}/08-24-inspiration-flow/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-flow}/08-24-inspiration-flow/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-flow}/08-24-inspiration-flow/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08/08-24-inspiration-flow}/08-24-inspiration-flow/task.json (91%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/research/plugin-patterns.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/task.json (92%) diff --git a/.trellis/tasks/08-24-inspiration-capture/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-capture/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/design.md diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.md diff --git a/.trellis/tasks/08-24-inspiration-capture/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-capture/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/prd.md diff --git a/.trellis/tasks/08-24-inspiration-capture/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/task.json similarity index 91% rename from .trellis/tasks/08-24-inspiration-capture/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/task.json index f091446..22614ad 100644 --- a/.trellis/tasks/08-24-inspiration-capture/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/task.json @@ -3,7 +3,7 @@ "name": "inspiration-capture", "title": "Inspiration capture and organization (#33)", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "plugin package metadata, schema, migrations, capture store/routes/tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/08-24-inspiration-clients/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-clients/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/design.md diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.md diff --git a/.trellis/tasks/08-24-inspiration-clients/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-clients/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/prd.md diff --git a/.trellis/tasks/08-24-inspiration-clients/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/task.json similarity index 91% rename from .trellis/tasks/08-24-inspiration-clients/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/task.json index e9b8f7b..2826f79 100644 --- a/.trellis/tasks/08-24-inspiration-clients/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/task.json @@ -3,7 +3,7 @@ "name": "inspiration-clients", "title": "Inspiration CLI Web and report clients", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "CLI, Web contribution, report-facing helper, client tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-flow/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/design.md diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.md diff --git a/.trellis/tasks/08-24-inspiration-flow/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-flow/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/prd.md diff --git a/.trellis/tasks/08-24-inspiration-flow/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/task.json similarity index 91% rename from .trellis/tasks/08-24-inspiration-flow/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/task.json index 9a0fd30..41c781a 100644 --- a/.trellis/tasks/08-24-inspiration-flow/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/task.json @@ -3,7 +3,7 @@ "name": "inspiration-flow", "title": "Inspiration Flow surfacing (#34)", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "selector, flow store/service/routes/job/notification contract/tests", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/08-24-inspiration-plugin/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl diff --git a/.trellis/tasks/08-24-inspiration-plugin/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md similarity index 100% rename from .trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md diff --git a/.trellis/tasks/08-24-inspiration-plugin/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json similarity index 92% rename from .trellis/tasks/08-24-inspiration-plugin/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json index 1b798f4..02f4a09 100644 --- a/.trellis/tasks/08-24-inspiration-plugin/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json @@ -3,7 +3,7 @@ "name": "inspiration-plugin", "title": "Inspiration bundled plugin", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "plugins/inspiration + bundled registry/build/docs tracking", "package": null, @@ -11,7 +11,7 @@ "creator": "sc", "assignee": "sc", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/inspiration-plugin", "base_branch": "main", "worktree_path": null, diff --git a/README.md b/README.md index 75676fb..1a2838e 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Plugin API v1 的通知 named service 由 [GitHub Issue #35](https://github.com/ - **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。 - **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。 -- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/08-24-inspiration-plugin/)。 +- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/)。 插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。 From c21a40e1d0de3d19d3c7992fee2b2f919b1afe79 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:04:18 +0800 Subject: [PATCH 17/33] fix(task): flatten inspiration child archives --- .../{08-24-inspiration-capture => }/check.jsonl | 0 .../{08-24-inspiration-capture => }/design.md | 0 .../{08-24-inspiration-capture => }/implement.jsonl | 0 .../{08-24-inspiration-capture => }/implement.md | 0 .../{08-24-inspiration-capture => }/prd.md | 0 .../{08-24-inspiration-capture => }/task.json | 0 .../{08-24-inspiration-clients => }/check.jsonl | 0 .../{08-24-inspiration-clients => }/design.md | 0 .../{08-24-inspiration-clients => }/implement.jsonl | 0 .../{08-24-inspiration-clients => }/implement.md | 0 .../{08-24-inspiration-clients => }/prd.md | 0 .../{08-24-inspiration-clients => }/task.json | 0 .../{08-24-inspiration-flow => }/check.jsonl | 0 .../08-24-inspiration-flow/{08-24-inspiration-flow => }/design.md | 0 .../{08-24-inspiration-flow => }/implement.jsonl | 0 .../{08-24-inspiration-flow => }/implement.md | 0 .../08-24-inspiration-flow/{08-24-inspiration-flow => }/prd.md | 0 .../08-24-inspiration-flow/{08-24-inspiration-flow => }/task.json | 0 18 files changed, 0 insertions(+), 0 deletions(-) rename .trellis/tasks/archive/2026-08/08-24-inspiration-capture/{08-24-inspiration-capture => }/check.jsonl (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-capture/{08-24-inspiration-capture => }/design.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-capture/{08-24-inspiration-capture => }/implement.jsonl (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-capture/{08-24-inspiration-capture => }/implement.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-capture/{08-24-inspiration-capture => }/prd.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-capture/{08-24-inspiration-capture => }/task.json (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-clients/{08-24-inspiration-clients => }/check.jsonl (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-clients/{08-24-inspiration-clients => }/design.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-clients/{08-24-inspiration-clients => }/implement.jsonl (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-clients/{08-24-inspiration-clients => }/implement.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-clients/{08-24-inspiration-clients => }/prd.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-clients/{08-24-inspiration-clients => }/task.json (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-flow/{08-24-inspiration-flow => }/check.jsonl (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-flow/{08-24-inspiration-flow => }/design.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-flow/{08-24-inspiration-flow => }/implement.jsonl (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-flow/{08-24-inspiration-flow => }/implement.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-flow/{08-24-inspiration-flow => }/prd.md (100%) rename .trellis/tasks/archive/2026-08/08-24-inspiration-flow/{08-24-inspiration-flow => }/task.json (100%) diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-capture/08-24-inspiration-capture/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-clients/08-24-inspiration-clients/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/design.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/implement.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/prd.md rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json similarity index 100% rename from .trellis/tasks/archive/2026-08/08-24-inspiration-flow/08-24-inspiration-flow/task.json rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json From b751338a0aa300602716f82fcaf8a075ee012a3e Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:17:59 +0800 Subject: [PATCH 18/33] fix(integration): compose plugin CLI surfaces --- src/cli/index.ts | 1227 +++++++++++++++++++++++----------------------- 1 file changed, 614 insertions(+), 613 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 04f0773..0ac9a67 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -834,837 +834,838 @@ withJson( }) ); -type InspirationLifecycleStatus = "inbox" | "kept" | "archived"; -type InspirationFlowOutcome = - | "viewed" - | "continued" - | "kept" - | "later" - | "archived"; - -const inspirationApiPrefix = "/api/plugins/inspiration"; +// el schedule list|show|add|edit|confirm|snooze|done|cancel +type ScheduleCliItem = { + id: string; + title: string; + description: string | null; + scheduledStartAt: string; + scheduledEndAt: string | null; + timezone: string; + priority: number; + status: "scheduled" | "active" | "done" | "cancelled"; + nextReminderAt: string | null; + confirmedStartAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + version: number; + awaitingConfirmation: boolean; +}; -function inspirationInteger(value: string, option: string, minimum = 0): number { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < minimum) { - throw new CliUsageError(`${option} 必须是大于或等于 ${minimum} 的整数`); +function parseScheduleExpectedVersion(value: string): number { + if (!/^[1-9]\d*$/.test(value)) { + throw new CliUsageError("--expected-version 必须是大于或等于 1 的整数"); } - return parsed; -} - -function inspirationLimit(value: string, option: string): number { - const parsed = inspirationInteger(value, option, 1); - if (parsed > 100) throw new CliUsageError(`${option} 必须是 1 到 100 的整数`); - return parsed; -} - -function inspirationBoolean(value: string, option: string): boolean { - if (value === "true") return true; - if (value === "false") return false; - throw new CliUsageError(`${option} 只能是 true 或 false`); -} - -function inspirationStatus(value: string, allowArchived = true): InspirationLifecycleStatus { - if (value === "inbox" || value === "kept" || (allowArchived && value === "archived")) { - return value; + const version = Number(value); + if (!Number.isSafeInteger(version)) { + throw new CliUsageError("--expected-version 必须是安全整数"); } - throw new CliUsageError( - allowArchived - ? "status 只能是 inbox、kept 或 archived" - : "status 只能是 inbox 或 kept" - ); + return version; } -function inspirationOutcome(value: string): InspirationFlowOutcome { - if ( - value === "viewed" || - value === "continued" || - value === "kept" || - value === "later" || - value === "archived" - ) { - return value; +function parseSchedulePriority(value: string): number { + if (!/^-?\d+$/.test(value)) { + throw new CliUsageError("--priority 必须是整数"); } - throw new CliUsageError( - "outcome 只能是 viewed、continued、kept、later 或 archived" - ); -} - -function inspirationMinute(value: string, option: string): number { - const match = /^(\d{2}):(\d{2})$/.exec(value); - if (!match) throw new CliUsageError(`${option} 必须是 HH:mm`); - const hour = Number(match[1]); - const minute = Number(match[2]); - if (hour > 23 || minute > 59) { - throw new CliUsageError(`${option} 必须是有效的 24 小时时间`); + const priority = Number(value); + if (!Number.isSafeInteger(priority)) { + throw new CliUsageError("--priority 必须是安全整数"); } - return hour * 60 + minute; + return priority; } -function inspirationItems(result: any): any[] { - if (Array.isArray(result)) return result; - if (Array.isArray(result?.items)) return result.items; - return []; +function scheduleItemPath(id: string): string { + return `/api/plugins/schedule/items/${encodeURIComponent(id)}`; } -function printInspirations(result: any): void { - const items = inspirationItems(result); - if (items.length === 0) { - console.log("暂无灵感"); - return; - } - for (const item of items) { - const tags = item.tags?.length ? ` #${item.tags.join(" #")}` : ""; - const project = item.project ? ` · ${item.project}` : ""; - console.log(`${item.id}\tv${item.version}\t${item.status}${project}${tags}`); - console.log(` ${item.content}`); - } +function printScheduleItem(item: ScheduleCliItem): void { + const icon = item.status === "done" + ? "✓" + : item.status === "active" + ? "▶" + : item.status === "cancelled" + ? "✗" + : item.awaitingConfirmation + ? "!" + : "○"; + console.log( + `${icon} ${item.title} [${item.id}] ${item.status} · ${item.scheduledStartAt} (${item.timezone}) · v${item.version}` + ); } -const inspiration = program - .command("inspiration") - .description("独立捕捉、整理灵感并使用确定性的 Inspiration Flow;不依赖活跃记录。") +const schedule = program + .command("schedule") + .description("管理 Schedule 插件日程;提醒不会自动开始任务,状态只由显式命令改变。") .addHelpText( "after", ` +时间格式: + 时间点必须是带 Z 或数字偏移的 ISO-8601,例如 2026-08-24T09:00:00+08:00。 + timezone 必须是 IANA 时区,例如 Asia/Shanghai;它只保存显示意图,不替代时间点偏移。 + 示例: - $ el inspiration capture "为发布页画一张对照图" --tags design,launch - $ el inspiration list --statuses inbox,kept --json - $ el inspiration flow next --json - $ el inspiration flow outcome <delivery-id> later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120 + $ el schedule list --from 2026-08-24T00:00:00+08:00 --to 2026-08-25T00:00:00+08:00 + $ el schedule add "设计评审" --start 2026-08-24T09:00:00+08:00 --timezone Asia/Shanghai + $ el schedule confirm <id> --expected-version 1 --json ` ); withJson( - inspiration - .command("capture <content>") - .description("捕捉一条独立灵感;status 只能是 inbox 或 kept,默认 inbox。") - .option("-t, --tags <tags>", "标签,逗号分隔,如 design,launch") - .option("-p, --project <project>", "可选自由文本项目分组") - .option("--status <status>", "生命周期状态: inbox | kept", "inbox") + schedule + .command("list") + .description("列出日程;范围为 [from,to),status 可为 scheduled,active,done,cancelled 的逗号列表。") + .option("--from <ISO>", "范围起点,带 Z 或数字偏移的 ISO-8601") + .option("--to <ISO>", "范围终点,带 Z 或数字偏移的 ISO-8601") + .option("--status <csv>", "状态列表: scheduled,active,done,cancelled") .addHelpText( "after", ` 示例: - $ el inspiration capture "试试更短的 onboarding" --tags product,ux - $ el inspiration capture "保留这条原则" --status kept --project EchoLog --json + $ el schedule list + $ el schedule list --from 2026-08-24T00:00:00+08:00 --to 2026-08-25T00:00:00+08:00 --status scheduled,active --json ` ) ).action( - action(async ( - thisCommand, - content: string, - opts: { tags?: string; project?: string; status: string } - ) => { - const created = await post(`${inspirationApiPrefix}/inspirations`, { - content, - tags: splitCsv(opts.tags), - project: opts.project?.trim() || null, - status: inspirationStatus(opts.status, false), - }); - printSuccess(thisCommand, created, () => { - console.log(`✓ 已捕捉灵感 [${(created as any).id}] v${(created as any).version}`); - console.log(` ${(created as any).content}`); + action(async (thisCommand, opts: { from?: string; to?: string; status?: string }) => { + const query = new URLSearchParams(); + if (opts.from) query.set("from", opts.from); + if (opts.to) query.set("to", opts.to); + if (opts.status) query.set("status", opts.status); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + const items = await api<ScheduleCliItem[]>(`/api/plugins/schedule/items${suffix}`); + printSuccess(thisCommand, items, () => { + if (items.length === 0) { + console.log("暂无日程"); + return; + } + for (const item of items) printScheduleItem(item); }); }) ); withJson( - inspiration - .command("list") - .alias("inbox") - .description("列出或筛选灵感;支持文本、标签、项目、生命周期与归档历史。") - .option("--text <query>", "正文包含的文本") - .option("--tags <tags>", "必须匹配的标签,逗号分隔") - .option("--project <project>", "精确项目分组") - .option("--statuses <statuses>", "状态,逗号分隔: inbox | kept | archived") - .option("--include-archived", "包含 archived 历史") - .option("--limit <n>", "返回数量,范围 1–100", "50") - .option("--created-before <iso>", "只看此创建时间之前,ISO 8601 且包含时区") - .option("--created-after <iso>", "只看此创建时间之后,ISO 8601 且包含时区") - .option("--cursor <cursor>", "上一页响应的 opaque nextCursor") + schedule + .command("show <id>") + .description("查看一条日程;id 来自 el schedule list。") .addHelpText( "after", ` 示例: - $ el inspiration list - $ el inspiration inbox --text onboarding --tags ux,product - $ el inspiration list --statuses kept,archived --include-archived --created-before 2026-08-24T12:00:00+08:00 --json + $ el schedule show <id> + $ el schedule show <id> --json ` ) ).action( - action(async (thisCommand, opts: { - text?: string; - tags?: string; - project?: string; - statuses?: string; - includeArchived?: boolean; - limit: string; - createdBefore?: string; - createdAfter?: string; - cursor?: string; - }) => { - const params = new URLSearchParams(); - if (opts.text) params.set("text", opts.text); - for (const tag of splitCsv(opts.tags)) params.append("tag", tag); - if (opts.project) params.set("project", opts.project); - if (opts.statuses) { - const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value)); - for (const status of statuses) params.append("status", status); - } - if (opts.includeArchived) params.set("includeArchived", "true"); - params.set("limit", String(inspirationLimit(opts.limit, "--limit"))); - if (opts.createdBefore) params.set("createdBefore", opts.createdBefore); - if (opts.createdAfter) params.set("createdAfter", opts.createdAfter); - if (opts.cursor) params.set("cursor", opts.cursor); - const result = await api(`${inspirationApiPrefix}/inspirations?${params}`); - printSuccess(thisCommand, result, () => printInspirations(result)); + action(async (thisCommand, id: string) => { + const item = await api<ScheduleCliItem>(scheduleItemPath(id)); + printSuccess(thisCommand, item, () => { + printScheduleItem(item); + console.log(` 描述: ${item.description ?? "-"}`); + console.log(` 计划结束: ${item.scheduledEndAt ?? "-"}`); + console.log(` 下次提醒: ${item.nextReminderAt ?? "-"}`); + console.log(` 确认开始: ${item.confirmedStartAt ?? "-"}`); + console.log(` 优先级: ${item.priority}`); + }); }) ); withJson( - inspiration - .command("show <id>") - .description("查看一条灵感;id 来自 inspiration list。") - .addHelpText("after", `\n示例:\n $ el inspiration show <id> --json\n`) + schedule + .command("add <title>") + .description("新增 scheduled 日程;到达 start 只提醒,不会自动开始或创建 Core record。") + .requiredOption("--start <ISO>", "计划开始,带 Z 或数字偏移的 ISO-8601") + .requiredOption("--timezone <IANA>", "显示时区,例如 Asia/Shanghai") + .option("--description <text>", "日程描述") + .option("--end <ISO>", "计划结束,带 Z 或数字偏移的 ISO-8601") + .option("--priority <n>", "整数优先级;取值范围由服务端校验") + .option("--remind-at <ISO>", "首次提醒时间,带 Z 或数字偏移;省略时等于 start") + .option("--no-reminder", "创建时不设置提醒") + .addHelpText( + "after", + ` +示例: + $ el schedule add "设计评审" --start 2026-08-24T09:00:00+08:00 --end 2026-08-24T10:00:00+08:00 --timezone Asia/Shanghai + $ el schedule add "发布检查" --start 2026-08-24T18:00:00Z --timezone UTC --remind-at 2026-08-24T17:45:00Z --priority 2 --json +` + ) ).action( - action(async (thisCommand, id: string) => { - const item = await api(`${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`); - printSuccess(thisCommand, item, () => printInspirations([item])); + action(async (thisCommand, title: string, opts: { + start: string; + timezone: string; + description?: string; + end?: string; + priority?: string; + remindAt?: string; + reminder?: boolean; + }) => { + if (opts.reminder === false && opts.remindAt !== undefined) { + throw new CliUsageError("--remind-at 和 --no-reminder 不能同时使用"); + } + const body: Record<string, unknown> = { + title, + scheduledStartAt: opts.start, + timezone: opts.timezone, + }; + if (opts.description !== undefined) body.description = opts.description; + if (opts.end !== undefined) body.scheduledEndAt = opts.end; + if (opts.priority !== undefined) body.priority = parseSchedulePriority(opts.priority); + if (opts.remindAt !== undefined) body.nextReminderAt = opts.remindAt; + if (opts.reminder === false) body.nextReminderAt = null; + + const item = await post<ScheduleCliItem>("/api/plugins/schedule/items", body); + printSuccess(thisCommand, item, () => { + console.log(`✓ 已添加日程: ${item.title} [${item.id}] · v${item.version}`); + }); }) ); withJson( - inspiration + schedule .command("edit <id>") - .description("按 expectedVersion 编辑正文、标签、项目或 inbox/kept 状态;冲突返回 409。") - .requiredOption("--version <n>", "当前 inspiration version,必须与服务端一致") - .option("--content <content>", "替换正文") - .option("--tags <tags>", "替换标签,逗号分隔;空字符串清空") - .option("--project <project>", "替换项目分组") - .option("--clear-project", "清除项目分组") - .option("--status <status>", "生命周期状态: inbox | kept") + .description("编辑仍为 scheduled 的日程;必须携带当前 expectedVersion,冲突由服务端返回 409。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + .option("--title <title>", "新标题") + .option("--description <text>", "新描述") + .option("--clear-description", "将描述设为 null") + .option("--start <ISO>", "计划开始,带 Z 或数字偏移的 ISO-8601") + .option("--end <ISO>", "计划结束,带 Z 或数字偏移的 ISO-8601") + .option("--clear-end", "将计划结束设为 null") + .option("--timezone <IANA>", "显示时区,例如 Asia/Shanghai") + .option("--priority <n>", "整数优先级;取值范围由服务端校验") + .option("--remind-at <ISO>", "下次提醒时间,带 Z 或数字偏移") + .option("--clear-reminder", "将下次提醒设为 null") .addHelpText( "after", ` 示例: - $ el inspiration edit <id> --version 2 --content "更明确的想法" --tags product,copy - $ el inspiration edit <id> --version 3 --clear-project --status kept --json + $ el schedule edit <id> --expected-version 1 --title "设计评审(更新)" + $ el schedule edit <id> --expected-version 2 --start 2026-08-24T10:00:00+08:00 --timezone Asia/Shanghai --json + $ el schedule edit <id> --expected-version 3 --clear-end --clear-reminder ` ) ).action( action(async (thisCommand, id: string, opts: { - version: string; - content?: string; - tags?: string; - project?: string; - clearProject?: boolean; - status?: string; + expectedVersion: string; + title?: string; + description?: string; + clearDescription?: boolean; + start?: string; + end?: string; + clearEnd?: boolean; + timezone?: string; + priority?: string; + remindAt?: string; + clearReminder?: boolean; }) => { - if (opts.project != null && opts.clearProject) { - throw new CliUsageError("--project 和 --clear-project 不能同时使用"); + if (opts.description !== undefined && opts.clearDescription) { + throw new CliUsageError("--description 和 --clear-description 不能同时使用"); + } + if (opts.end !== undefined && opts.clearEnd) { + throw new CliUsageError("--end 和 --clear-end 不能同时使用"); + } + if (opts.remindAt !== undefined && opts.clearReminder) { + throw new CliUsageError("--remind-at 和 --clear-reminder 不能同时使用"); } const body: Record<string, unknown> = { - expectedVersion: inspirationInteger(opts.version, "--version", 1), + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), }; - if (opts.content != null) body.content = opts.content; - if (opts.tags != null) body.tags = splitCsv(opts.tags); - if (opts.project != null) body.project = opts.project.trim() || null; - if (opts.clearProject) body.project = null; - if (opts.status != null) body.status = inspirationStatus(opts.status, false); - if (Object.keys(body).length === 1) { - throw new CliUsageError("至少指定 --content、--tags、--project、--clear-project 或 --status 之一"); - } - const updated = await patch( - `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`, - body - ); - printSuccess(thisCommand, updated, () => { - console.log(`✓ 已更新灵感 [${(updated as any).id}] v${(updated as any).version}`); + if (opts.title !== undefined) body.title = opts.title; + if (opts.description !== undefined) body.description = opts.description; + if (opts.clearDescription) body.description = null; + if (opts.start !== undefined) body.scheduledStartAt = opts.start; + if (opts.end !== undefined) body.scheduledEndAt = opts.end; + if (opts.clearEnd) body.scheduledEndAt = null; + if (opts.timezone !== undefined) body.timezone = opts.timezone; + if (opts.priority !== undefined) body.priority = parseSchedulePriority(opts.priority); + if (opts.remindAt !== undefined) body.nextReminderAt = opts.remindAt; + if (opts.clearReminder) body.nextReminderAt = null; + + const item = await patch<ScheduleCliItem>(scheduleItemPath(id), body); + printSuccess(thisCommand, item, () => { + console.log(`✓ 已更新日程: ${item.title} [${item.id}] · v${item.version}`); }); }) ); -for (const operation of ["archive", "restore"] as const) { - withJson( - inspiration - .command(`${operation} <id>`) - .description( - operation === "archive" - ? "按 expectedVersion 归档灵感;历史仍可查询。" - : "按 expectedVersion 将已归档灵感恢复到 inbox。" - ) - .requiredOption("--version <n>", "当前 inspiration version,必须与服务端一致") - .addHelpText( - "after", - `\n示例:\n $ el inspiration ${operation} <id> --version 2 --json\n` - ) - ).action( - action(async (thisCommand, id: string, opts: { version: string }) => { - const result = await post( - `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}/${operation}`, - { expectedVersion: inspirationInteger(opts.version, "--version", 1) } - ); - printSuccess(thisCommand, result, () => { - console.log( - `✓ 灵感已${operation === "archive" ? "归档" : "恢复"} [${(result as any).id}] v${(result as any).version}` - ); - }); - }) - ); -} - -const inspirationFlow = inspiration - .command("flow") - .description("手动浮现灵感、记录用户结果,并查看 Flow 设置与投递历史。") - .addHelpText( - "after", - ` -示例: - $ el inspiration flow next --idempotency-key manual-20260824 --json - $ el inspiration flow deliveries --limit 20 -` - ); - withJson( - inspirationFlow - .command("next") - .description("使用服务端确定性选择器浮现下一条;不在客户端推断候选。") - .option("--idempotency-key <key>", "可选手动幂等键,最长 200 字符") + schedule + .command("confirm <id>") + .description("显式确认开始:scheduled -> active;confirmedStartAt 由服务端记录为确认时刻。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") .addHelpText( "after", - `\n示例:\n $ el inspiration flow next\n $ el inspiration flow next --idempotency-key morning-review --json\n` + ` +示例: + $ el schedule confirm <id> --expected-version 1 + $ el schedule confirm <id> --expected-version 1 --json +` ) ).action( - action(async (thisCommand, opts: { idempotencyKey?: string }) => { - const result = await post(`${inspirationApiPrefix}/flow/next`, - opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {} - ); - printSuccess(thisCommand, result, () => { - const candidate = (result as any).candidate; - if (!candidate) { - console.log("暂无可浮现的灵感"); - for (const reason of (result as any).explanation ?? []) console.log(` - ${reason}`); - return; - } - console.log(`${candidate.inspiration.content}`); - console.log(` inspiration ${candidate.inspiration.id} v${candidate.inspiration.version}`); - console.log(` delivery ${candidate.delivery.id} v${candidate.delivery.version}`); - for (const reason of candidate.explanation ?? []) console.log(` - ${reason}`); + action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/confirm-start`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + }); + printSuccess(thisCommand, item, () => { + console.log(`▶ 已确认开始: ${item.title} [${item.id}] · v${item.version}`); }); }) ); withJson( - inspirationFlow - .command("outcome <deliveryId> <outcome>") - .description("记录 Flow 结果: viewed | continued | kept | later | archived。") - .requiredOption("--delivery-version <n>", "当前 delivery version") - .requiredOption("--inspiration-version <n>", "候选 inspiration version") - .option("--snooze-minutes <n>", "later 的稍后分钟数;省略时使用服务端默认值") + schedule + .command("snooze <id>") + .description("仅移动 scheduled 日程的 nextReminderAt;不会改变状态或自动开始。") + .requiredOption("--until <ISO>", "新的提醒时间,带 Z 或数字偏移的 ISO-8601") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") .addHelpText( "after", ` 示例: - $ el inspiration flow outcome <delivery-id> viewed --delivery-version 1 --inspiration-version 3 - $ el inspiration flow outcome <delivery-id> later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120 --json + $ el schedule snooze <id> --until 2026-08-24T09:15:00+08:00 --expected-version 1 + $ el schedule snooze <id> --until 2026-08-24T01:15:00Z --expected-version 1 --json ` ) ).action( - action(async (thisCommand, deliveryId: string, outcomeValue: string, opts: { - deliveryVersion: string; - inspirationVersion: string; - snoozeMinutes?: string; - }) => { - const outcome = inspirationOutcome(outcomeValue); - const body: Record<string, unknown> = { - expectedDeliveryVersion: inspirationInteger(opts.deliveryVersion, "--delivery-version", 1), - expectedInspirationVersion: inspirationInteger(opts.inspirationVersion, "--inspiration-version", 1), - outcome, - }; - if (opts.snoozeMinutes != null) { - if (outcome !== "later") { - throw new CliUsageError("--snooze-minutes 只能与 outcome=later 一起使用"); - } - body.snoozeMinutes = inspirationInteger(opts.snoozeMinutes, "--snooze-minutes", 1); - } - const result = await post( - `${inspirationApiPrefix}/flow/deliveries/${encodeURIComponent(deliveryId)}/outcome`, - body - ); - printSuccess(thisCommand, result, () => { - console.log(`✓ 已记录 Flow 结果: ${outcome}`); + action(async (thisCommand, id: string, opts: { until: string; expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/snooze`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + nextReminderAt: opts.until, }); - }) -); - -const inspirationFlowSettings = inspirationFlow - .command("settings") - .description("查看 Flow 设置;使用 settings set 提交完整的版本化设置。") - .addHelpText( - "after", - `\n示例:\n $ el inspiration flow settings --json\n $ el inspiration flow settings set --help\n` - ); - -withJson(inspirationFlowSettings).action( - action(async (thisCommand) => { - const settings = await api(`${inspirationApiPrefix}/flow/settings`); - printSuccess(thisCommand, settings, () => { - const value = settings as any; - console.log(`Flow: ${value.enabled ? "已启用" : "未启用"} · v${value.version}`); - console.log(` 周期 ${value.intervalMinutes} 分钟 · 冷却 ${value.cooldownMinutes} 分钟 · 每日上限 ${value.dailyLimit}`); - console.log(` 安静时间 ${formatMinute(value.quietStartMinute)}–${formatMinute(value.quietEndMinute)}`); + printSuccess(thisCommand, item, () => { + console.log(`⏰ 已延后提醒: ${item.title} · ${item.nextReminderAt} · v${item.version}`); }); }) ); withJson( - inspirationFlowSettings - .command("set") - .description("提交完整 FlowSettingsUpdate;所有选项必填,版本冲突返回 409。") - .requiredOption("--version <n>", "当前 settings version") - .requiredOption("--enabled <boolean>", "是否启用定时 Flow: true | false") - .requiredOption("--interval-minutes <n>", "定时检查间隔分钟数") - .requiredOption("--quiet-start <HH:mm>", "安静时间开始,HH:mm") - .requiredOption("--quiet-end <HH:mm>", "安静时间结束,HH:mm;开始晚于结束表示跨夜") - .requiredOption("--cooldown-minutes <n>", "同一灵感冷却分钟数") - .requiredOption("--daily-limit <n>", "每日浮现上限") - .requiredOption("--default-snooze-minutes <n>", "later 默认稍后分钟数") - .requiredOption("--statuses <statuses>", "候选状态,逗号分隔: inbox | kept") - .requiredOption("--tags <tags>", "可选标签筛选,逗号分隔;传空字符串表示不限") - .requiredOption("--projects <projects>", "可选项目筛选,逗号分隔;传空字符串表示不限") + schedule + .command("done <id>") + .description("显式完成 scheduled 或 active 日程;不会修改任何 Core record。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") .addHelpText( "after", ` 示例: - $ el inspiration flow settings set --version 1 --enabled true --interval-minutes 180 --quiet-start 22:00 --quiet-end 08:00 --cooldown-minutes 1440 --daily-limit 3 --default-snooze-minutes 120 --statuses inbox,kept --tags "" --projects "" --json + $ el schedule done <id> --expected-version 2 + $ el schedule done <id> --expected-version 2 --json ` ) ).action( - action(async (thisCommand, opts: { - version: string; - enabled: string; - intervalMinutes: string; - quietStart: string; - quietEnd: string; - cooldownMinutes: string; - dailyLimit: string; - defaultSnoozeMinutes: string; - statuses: string; - tags: string; - projects: string; - }) => { - const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value, false)); - if (statuses.length === 0) throw new CliUsageError("--statuses 至少包含 inbox 或 kept"); - const settings = await patch(`${inspirationApiPrefix}/flow/settings`, { - expectedVersion: inspirationInteger(opts.version, "--version", 1), - enabled: inspirationBoolean(opts.enabled, "--enabled"), - intervalMinutes: inspirationInteger(opts.intervalMinutes, "--interval-minutes", 1), - quietStartMinute: inspirationMinute(opts.quietStart, "--quiet-start"), - quietEndMinute: inspirationMinute(opts.quietEnd, "--quiet-end"), - cooldownMinutes: inspirationInteger(opts.cooldownMinutes, "--cooldown-minutes"), - dailyLimit: inspirationInteger(opts.dailyLimit, "--daily-limit", 1), - defaultSnoozeMinutes: inspirationInteger( - opts.defaultSnoozeMinutes, - "--default-snooze-minutes", - 1 - ), - statuses, - tags: splitCsv(opts.tags), - projects: splitCsv(opts.projects), + action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/complete`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), }); - printSuccess(thisCommand, settings, () => { - console.log(`✓ Flow 设置已保存 v${(settings as any).version}`); + printSuccess(thisCommand, item, () => { + console.log(`✓ 已完成日程: ${item.title} [${item.id}] · v${item.version}`); }); }) ); withJson( - inspirationFlow - .command("deliveries") - .description("查看 Flow 投递 ledger;不包含灵感正文。") - .option("--limit <n>", "返回数量,范围 1–100", "20") - .option("--before <iso>", "surfacedAt 游标,ISO 8601 且包含时区") + schedule + .command("cancel <id>") + .description("显式取消 scheduled 或 active 日程;忽略提醒本身不会取消。") + .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") .addHelpText( "after", - `\n示例:\n $ el inspiration flow deliveries --limit 20\n $ el inspiration flow deliveries --before 2026-08-24T12:00:00+08:00 --json\n` + ` +示例: + $ el schedule cancel <id> --expected-version 1 + $ el schedule cancel <id> --expected-version 1 --json +` ) ).action( - action(async (thisCommand, opts: { limit: string; before?: string }) => { - const params = new URLSearchParams({ - limit: String(inspirationLimit(opts.limit, "--limit")), + action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { + const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/cancel`, { + expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), }); - if (opts.before) params.set("before", opts.before); - const result = await api(`${inspirationApiPrefix}/flow/deliveries?${params}`); - printSuccess(thisCommand, result, () => { - const deliveries = Array.isArray((result as any).deliveries) - ? (result as any).deliveries - : []; - if (deliveries.length === 0) { - console.log("暂无 Flow 投递"); - return; - } - for (const delivery of deliveries) { - console.log( - `${delivery.id}\tv${delivery.version}\t${delivery.status}\t${delivery.outcome ?? "-"}\t${delivery.surfacedAt}` - ); - } + printSuccess(thisCommand, item, () => { + console.log(`✗ 已取消日程: ${item.title} [${item.id}] · v${item.version}`); }); }) ); -+// el schedule list|show|add|edit|confirm|snooze|done|cancel -type ScheduleCliItem = { - id: string; - title: string; - description: string | null; - scheduledStartAt: string; - scheduledEndAt: string | null; - timezone: string; - priority: number; - status: "scheduled" | "active" | "done" | "cancelled"; - nextReminderAt: string | null; - confirmedStartAt: string | null; - completedAt: string | null; - cancelledAt: string | null; - version: number; - awaitingConfirmation: boolean; -}; -function parseScheduleExpectedVersion(value: string): number { - if (!/^[1-9]\d*$/.test(value)) { - throw new CliUsageError("--expected-version 必须是大于或等于 1 的整数"); - } - const version = Number(value); - if (!Number.isSafeInteger(version)) { - throw new CliUsageError("--expected-version 必须是安全整数"); + +type InspirationLifecycleStatus = "inbox" | "kept" | "archived"; +type InspirationFlowOutcome = + | "viewed" + | "continued" + | "kept" + | "later" + | "archived"; + +const inspirationApiPrefix = "/api/plugins/inspiration"; + +function inspirationInteger(value: string, option: string, minimum = 0): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < minimum) { + throw new CliUsageError(`${option} 必须是大于或等于 ${minimum} 的整数`); } - return version; + return parsed; } -function parseSchedulePriority(value: string): number { - if (!/^-?\d+$/.test(value)) { - throw new CliUsageError("--priority 必须是整数"); +function inspirationLimit(value: string, option: string): number { + const parsed = inspirationInteger(value, option, 1); + if (parsed > 100) throw new CliUsageError(`${option} 必须是 1 到 100 的整数`); + return parsed; +} + +function inspirationBoolean(value: string, option: string): boolean { + if (value === "true") return true; + if (value === "false") return false; + throw new CliUsageError(`${option} 只能是 true 或 false`); +} + +function inspirationStatus(value: string, allowArchived = true): InspirationLifecycleStatus { + if (value === "inbox" || value === "kept" || (allowArchived && value === "archived")) { + return value; } - const priority = Number(value); - if (!Number.isSafeInteger(priority)) { - throw new CliUsageError("--priority 必须是安全整数"); + throw new CliUsageError( + allowArchived + ? "status 只能是 inbox、kept 或 archived" + : "status 只能是 inbox 或 kept" + ); +} + +function inspirationOutcome(value: string): InspirationFlowOutcome { + if ( + value === "viewed" || + value === "continued" || + value === "kept" || + value === "later" || + value === "archived" + ) { + return value; } - return priority; + throw new CliUsageError( + "outcome 只能是 viewed、continued、kept、later 或 archived" + ); } -function scheduleItemPath(id: string): string { - return `/api/plugins/schedule/items/${encodeURIComponent(id)}`; +function inspirationMinute(value: string, option: string): number { + const match = /^(\d{2}):(\d{2})$/.exec(value); + if (!match) throw new CliUsageError(`${option} 必须是 HH:mm`); + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) { + throw new CliUsageError(`${option} 必须是有效的 24 小时时间`); + } + return hour * 60 + minute; } -function printScheduleItem(item: ScheduleCliItem): void { - const icon = item.status === "done" - ? "✓" - : item.status === "active" - ? "▶" - : item.status === "cancelled" - ? "✗" - : item.awaitingConfirmation - ? "!" - : "○"; - console.log( - `${icon} ${item.title} [${item.id}] ${item.status} · ${item.scheduledStartAt} (${item.timezone}) · v${item.version}` - ); +function inspirationItems(result: any): any[] { + if (Array.isArray(result)) return result; + if (Array.isArray(result?.items)) return result.items; + return []; } -const schedule = program - .command("schedule") - .description("管理 Schedule 插件日程;提醒不会自动开始任务,状态只由显式命令改变。") +function printInspirations(result: any): void { + const items = inspirationItems(result); + if (items.length === 0) { + console.log("暂无灵感"); + return; + } + for (const item of items) { + const tags = item.tags?.length ? ` #${item.tags.join(" #")}` : ""; + const project = item.project ? ` · ${item.project}` : ""; + console.log(`${item.id}\tv${item.version}\t${item.status}${project}${tags}`); + console.log(` ${item.content}`); + } +} + +const inspiration = program + .command("inspiration") + .description("独立捕捉、整理灵感并使用确定性的 Inspiration Flow;不依赖活跃记录。") .addHelpText( "after", ` -时间格式: - 时间点必须是带 Z 或数字偏移的 ISO-8601,例如 2026-08-24T09:00:00+08:00。 - timezone 必须是 IANA 时区,例如 Asia/Shanghai;它只保存显示意图,不替代时间点偏移。 - 示例: - $ el schedule list --from 2026-08-24T00:00:00+08:00 --to 2026-08-25T00:00:00+08:00 - $ el schedule add "设计评审" --start 2026-08-24T09:00:00+08:00 --timezone Asia/Shanghai - $ el schedule confirm <id> --expected-version 1 --json + $ el inspiration capture "为发布页画一张对照图" --tags design,launch + $ el inspiration list --statuses inbox,kept --json + $ el inspiration flow next --json + $ el inspiration flow outcome <delivery-id> later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120 ` ); withJson( - schedule - .command("list") - .description("列出日程;范围为 [from,to),status 可为 scheduled,active,done,cancelled 的逗号列表。") - .option("--from <ISO>", "范围起点,带 Z 或数字偏移的 ISO-8601") - .option("--to <ISO>", "范围终点,带 Z 或数字偏移的 ISO-8601") - .option("--status <csv>", "状态列表: scheduled,active,done,cancelled") + inspiration + .command("capture <content>") + .description("捕捉一条独立灵感;status 只能是 inbox 或 kept,默认 inbox。") + .option("-t, --tags <tags>", "标签,逗号分隔,如 design,launch") + .option("-p, --project <project>", "可选自由文本项目分组") + .option("--status <status>", "生命周期状态: inbox | kept", "inbox") .addHelpText( "after", ` 示例: - $ el schedule list - $ el schedule list --from 2026-08-24T00:00:00+08:00 --to 2026-08-25T00:00:00+08:00 --status scheduled,active --json + $ el inspiration capture "试试更短的 onboarding" --tags product,ux + $ el inspiration capture "保留这条原则" --status kept --project EchoLog --json ` ) ).action( - action(async (thisCommand, opts: { from?: string; to?: string; status?: string }) => { - const query = new URLSearchParams(); - if (opts.from) query.set("from", opts.from); - if (opts.to) query.set("to", opts.to); - if (opts.status) query.set("status", opts.status); - const suffix = query.size > 0 ? `?${query.toString()}` : ""; - const items = await api<ScheduleCliItem[]>(`/api/plugins/schedule/items${suffix}`); - printSuccess(thisCommand, items, () => { - if (items.length === 0) { - console.log("暂无日程"); - return; - } - for (const item of items) printScheduleItem(item); + action(async ( + thisCommand, + content: string, + opts: { tags?: string; project?: string; status: string } + ) => { + const created = await post(`${inspirationApiPrefix}/inspirations`, { + content, + tags: splitCsv(opts.tags), + project: opts.project?.trim() || null, + status: inspirationStatus(opts.status, false), + }); + printSuccess(thisCommand, created, () => { + console.log(`✓ 已捕捉灵感 [${(created as any).id}] v${(created as any).version}`); + console.log(` ${(created as any).content}`); }); }) ); withJson( - schedule - .command("show <id>") - .description("查看一条日程;id 来自 el schedule list。") + inspiration + .command("list") + .alias("inbox") + .description("列出或筛选灵感;支持文本、标签、项目、生命周期与归档历史。") + .option("--text <query>", "正文包含的文本") + .option("--tags <tags>", "必须匹配的标签,逗号分隔") + .option("--project <project>", "精确项目分组") + .option("--statuses <statuses>", "状态,逗号分隔: inbox | kept | archived") + .option("--include-archived", "包含 archived 历史") + .option("--limit <n>", "返回数量,范围 1–100", "50") + .option("--created-before <iso>", "只看此创建时间之前,ISO 8601 且包含时区") + .option("--created-after <iso>", "只看此创建时间之后,ISO 8601 且包含时区") + .option("--cursor <cursor>", "上一页响应的 opaque nextCursor") .addHelpText( "after", ` 示例: - $ el schedule show <id> - $ el schedule show <id> --json + $ el inspiration list + $ el inspiration inbox --text onboarding --tags ux,product + $ el inspiration list --statuses kept,archived --include-archived --created-before 2026-08-24T12:00:00+08:00 --json ` ) ).action( - action(async (thisCommand, id: string) => { - const item = await api<ScheduleCliItem>(scheduleItemPath(id)); - printSuccess(thisCommand, item, () => { - printScheduleItem(item); - console.log(` 描述: ${item.description ?? "-"}`); - console.log(` 计划结束: ${item.scheduledEndAt ?? "-"}`); - console.log(` 下次提醒: ${item.nextReminderAt ?? "-"}`); - console.log(` 确认开始: ${item.confirmedStartAt ?? "-"}`); - console.log(` 优先级: ${item.priority}`); - }); + action(async (thisCommand, opts: { + text?: string; + tags?: string; + project?: string; + statuses?: string; + includeArchived?: boolean; + limit: string; + createdBefore?: string; + createdAfter?: string; + cursor?: string; + }) => { + const params = new URLSearchParams(); + if (opts.text) params.set("text", opts.text); + for (const tag of splitCsv(opts.tags)) params.append("tag", tag); + if (opts.project) params.set("project", opts.project); + if (opts.statuses) { + const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value)); + for (const status of statuses) params.append("status", status); + } + if (opts.includeArchived) params.set("includeArchived", "true"); + params.set("limit", String(inspirationLimit(opts.limit, "--limit"))); + if (opts.createdBefore) params.set("createdBefore", opts.createdBefore); + if (opts.createdAfter) params.set("createdAfter", opts.createdAfter); + if (opts.cursor) params.set("cursor", opts.cursor); + const result = await api(`${inspirationApiPrefix}/inspirations?${params}`); + printSuccess(thisCommand, result, () => printInspirations(result)); }) ); withJson( - schedule - .command("add <title>") - .description("新增 scheduled 日程;到达 start 只提醒,不会自动开始或创建 Core record。") - .requiredOption("--start <ISO>", "计划开始,带 Z 或数字偏移的 ISO-8601") - .requiredOption("--timezone <IANA>", "显示时区,例如 Asia/Shanghai") - .option("--description <text>", "日程描述") - .option("--end <ISO>", "计划结束,带 Z 或数字偏移的 ISO-8601") - .option("--priority <n>", "整数优先级;取值范围由服务端校验") - .option("--remind-at <ISO>", "首次提醒时间,带 Z 或数字偏移;省略时等于 start") - .option("--no-reminder", "创建时不设置提醒") - .addHelpText( - "after", - ` -示例: - $ el schedule add "设计评审" --start 2026-08-24T09:00:00+08:00 --end 2026-08-24T10:00:00+08:00 --timezone Asia/Shanghai - $ el schedule add "发布检查" --start 2026-08-24T18:00:00Z --timezone UTC --remind-at 2026-08-24T17:45:00Z --priority 2 --json -` - ) + inspiration + .command("show <id>") + .description("查看一条灵感;id 来自 inspiration list。") + .addHelpText("after", `\n示例:\n $ el inspiration show <id> --json\n`) ).action( - action(async (thisCommand, title: string, opts: { - start: string; - timezone: string; - description?: string; - end?: string; - priority?: string; - remindAt?: string; - reminder?: boolean; - }) => { - if (opts.reminder === false && opts.remindAt !== undefined) { - throw new CliUsageError("--remind-at 和 --no-reminder 不能同时使用"); - } - const body: Record<string, unknown> = { - title, - scheduledStartAt: opts.start, - timezone: opts.timezone, - }; - if (opts.description !== undefined) body.description = opts.description; - if (opts.end !== undefined) body.scheduledEndAt = opts.end; - if (opts.priority !== undefined) body.priority = parseSchedulePriority(opts.priority); - if (opts.remindAt !== undefined) body.nextReminderAt = opts.remindAt; - if (opts.reminder === false) body.nextReminderAt = null; - - const item = await post<ScheduleCliItem>("/api/plugins/schedule/items", body); - printSuccess(thisCommand, item, () => { - console.log(`✓ 已添加日程: ${item.title} [${item.id}] · v${item.version}`); - }); + action(async (thisCommand, id: string) => { + const item = await api(`${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`); + printSuccess(thisCommand, item, () => printInspirations([item])); }) ); withJson( - schedule + inspiration .command("edit <id>") - .description("编辑仍为 scheduled 的日程;必须携带当前 expectedVersion,冲突由服务端返回 409。") - .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") - .option("--title <title>", "新标题") - .option("--description <text>", "新描述") - .option("--clear-description", "将描述设为 null") - .option("--start <ISO>", "计划开始,带 Z 或数字偏移的 ISO-8601") - .option("--end <ISO>", "计划结束,带 Z 或数字偏移的 ISO-8601") - .option("--clear-end", "将计划结束设为 null") - .option("--timezone <IANA>", "显示时区,例如 Asia/Shanghai") - .option("--priority <n>", "整数优先级;取值范围由服务端校验") - .option("--remind-at <ISO>", "下次提醒时间,带 Z 或数字偏移") - .option("--clear-reminder", "将下次提醒设为 null") + .description("按 expectedVersion 编辑正文、标签、项目或 inbox/kept 状态;冲突返回 409。") + .requiredOption("--version <n>", "当前 inspiration version,必须与服务端一致") + .option("--content <content>", "替换正文") + .option("--tags <tags>", "替换标签,逗号分隔;空字符串清空") + .option("--project <project>", "替换项目分组") + .option("--clear-project", "清除项目分组") + .option("--status <status>", "生命周期状态: inbox | kept") .addHelpText( "after", ` 示例: - $ el schedule edit <id> --expected-version 1 --title "设计评审(更新)" - $ el schedule edit <id> --expected-version 2 --start 2026-08-24T10:00:00+08:00 --timezone Asia/Shanghai --json - $ el schedule edit <id> --expected-version 3 --clear-end --clear-reminder + $ el inspiration edit <id> --version 2 --content "更明确的想法" --tags product,copy + $ el inspiration edit <id> --version 3 --clear-project --status kept --json ` ) ).action( action(async (thisCommand, id: string, opts: { - expectedVersion: string; - title?: string; - description?: string; - clearDescription?: boolean; - start?: string; - end?: string; - clearEnd?: boolean; - timezone?: string; - priority?: string; - remindAt?: string; - clearReminder?: boolean; + version: string; + content?: string; + tags?: string; + project?: string; + clearProject?: boolean; + status?: string; }) => { - if (opts.description !== undefined && opts.clearDescription) { - throw new CliUsageError("--description 和 --clear-description 不能同时使用"); - } - if (opts.end !== undefined && opts.clearEnd) { - throw new CliUsageError("--end 和 --clear-end 不能同时使用"); - } - if (opts.remindAt !== undefined && opts.clearReminder) { - throw new CliUsageError("--remind-at 和 --clear-reminder 不能同时使用"); - } - const body: Record<string, unknown> = { - expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), - }; - if (opts.title !== undefined) body.title = opts.title; - if (opts.description !== undefined) body.description = opts.description; - if (opts.clearDescription) body.description = null; - if (opts.start !== undefined) body.scheduledStartAt = opts.start; - if (opts.end !== undefined) body.scheduledEndAt = opts.end; - if (opts.clearEnd) body.scheduledEndAt = null; - if (opts.timezone !== undefined) body.timezone = opts.timezone; - if (opts.priority !== undefined) body.priority = parseSchedulePriority(opts.priority); - if (opts.remindAt !== undefined) body.nextReminderAt = opts.remindAt; - if (opts.clearReminder) body.nextReminderAt = null; - - const item = await patch<ScheduleCliItem>(scheduleItemPath(id), body); - printSuccess(thisCommand, item, () => { - console.log(`✓ 已更新日程: ${item.title} [${item.id}] · v${item.version}`); + if (opts.project != null && opts.clearProject) { + throw new CliUsageError("--project 和 --clear-project 不能同时使用"); + } + const body: Record<string, unknown> = { + expectedVersion: inspirationInteger(opts.version, "--version", 1), + }; + if (opts.content != null) body.content = opts.content; + if (opts.tags != null) body.tags = splitCsv(opts.tags); + if (opts.project != null) body.project = opts.project.trim() || null; + if (opts.clearProject) body.project = null; + if (opts.status != null) body.status = inspirationStatus(opts.status, false); + if (Object.keys(body).length === 1) { + throw new CliUsageError("至少指定 --content、--tags、--project、--clear-project 或 --status 之一"); + } + const updated = await patch( + `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`, + body + ); + printSuccess(thisCommand, updated, () => { + console.log(`✓ 已更新灵感 [${(updated as any).id}] v${(updated as any).version}`); }); }) ); +for (const operation of ["archive", "restore"] as const) { + withJson( + inspiration + .command(`${operation} <id>`) + .description( + operation === "archive" + ? "按 expectedVersion 归档灵感;历史仍可查询。" + : "按 expectedVersion 将已归档灵感恢复到 inbox。" + ) + .requiredOption("--version <n>", "当前 inspiration version,必须与服务端一致") + .addHelpText( + "after", + `\n示例:\n $ el inspiration ${operation} <id> --version 2 --json\n` + ) + ).action( + action(async (thisCommand, id: string, opts: { version: string }) => { + const result = await post( + `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}/${operation}`, + { expectedVersion: inspirationInteger(opts.version, "--version", 1) } + ); + printSuccess(thisCommand, result, () => { + console.log( + `✓ 灵感已${operation === "archive" ? "归档" : "恢复"} [${(result as any).id}] v${(result as any).version}` + ); + }); + }) + ); +} + +const inspirationFlow = inspiration + .command("flow") + .description("手动浮现灵感、记录用户结果,并查看 Flow 设置与投递历史。") + .addHelpText( + "after", + ` +示例: + $ el inspiration flow next --idempotency-key manual-20260824 --json + $ el inspiration flow deliveries --limit 20 +` + ); + withJson( - schedule - .command("confirm <id>") - .description("显式确认开始:scheduled -> active;confirmedStartAt 由服务端记录为确认时刻。") - .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + inspirationFlow + .command("next") + .description("使用服务端确定性选择器浮现下一条;不在客户端推断候选。") + .option("--idempotency-key <key>", "可选手动幂等键,最长 200 字符") .addHelpText( "after", - ` -示例: - $ el schedule confirm <id> --expected-version 1 - $ el schedule confirm <id> --expected-version 1 --json -` + `\n示例:\n $ el inspiration flow next\n $ el inspiration flow next --idempotency-key morning-review --json\n` ) ).action( - action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { - const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/confirm-start`, { - expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), - }); - printSuccess(thisCommand, item, () => { - console.log(`▶ 已确认开始: ${item.title} [${item.id}] · v${item.version}`); + action(async (thisCommand, opts: { idempotencyKey?: string }) => { + const result = await post(`${inspirationApiPrefix}/flow/next`, + opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {} + ); + printSuccess(thisCommand, result, () => { + const candidate = (result as any).candidate; + if (!candidate) { + console.log("暂无可浮现的灵感"); + for (const reason of (result as any).explanation ?? []) console.log(` - ${reason}`); + return; + } + console.log(`${candidate.inspiration.content}`); + console.log(` inspiration ${candidate.inspiration.id} v${candidate.inspiration.version}`); + console.log(` delivery ${candidate.delivery.id} v${candidate.delivery.version}`); + for (const reason of candidate.explanation ?? []) console.log(` - ${reason}`); }); }) ); withJson( - schedule - .command("snooze <id>") - .description("仅移动 scheduled 日程的 nextReminderAt;不会改变状态或自动开始。") - .requiredOption("--until <ISO>", "新的提醒时间,带 Z 或数字偏移的 ISO-8601") - .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + inspirationFlow + .command("outcome <deliveryId> <outcome>") + .description("记录 Flow 结果: viewed | continued | kept | later | archived。") + .requiredOption("--delivery-version <n>", "当前 delivery version") + .requiredOption("--inspiration-version <n>", "候选 inspiration version") + .option("--snooze-minutes <n>", "later 的稍后分钟数;省略时使用服务端默认值") .addHelpText( "after", ` 示例: - $ el schedule snooze <id> --until 2026-08-24T09:15:00+08:00 --expected-version 1 - $ el schedule snooze <id> --until 2026-08-24T01:15:00Z --expected-version 1 --json + $ el inspiration flow outcome <delivery-id> viewed --delivery-version 1 --inspiration-version 3 + $ el inspiration flow outcome <delivery-id> later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120 --json ` ) ).action( - action(async (thisCommand, id: string, opts: { until: string; expectedVersion: string }) => { - const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/snooze`, { - expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), - nextReminderAt: opts.until, + action(async (thisCommand, deliveryId: string, outcomeValue: string, opts: { + deliveryVersion: string; + inspirationVersion: string; + snoozeMinutes?: string; + }) => { + const outcome = inspirationOutcome(outcomeValue); + const body: Record<string, unknown> = { + expectedDeliveryVersion: inspirationInteger(opts.deliveryVersion, "--delivery-version", 1), + expectedInspirationVersion: inspirationInteger(opts.inspirationVersion, "--inspiration-version", 1), + outcome, + }; + if (opts.snoozeMinutes != null) { + if (outcome !== "later") { + throw new CliUsageError("--snooze-minutes 只能与 outcome=later 一起使用"); + } + body.snoozeMinutes = inspirationInteger(opts.snoozeMinutes, "--snooze-minutes", 1); + } + const result = await post( + `${inspirationApiPrefix}/flow/deliveries/${encodeURIComponent(deliveryId)}/outcome`, + body + ); + printSuccess(thisCommand, result, () => { + console.log(`✓ 已记录 Flow 结果: ${outcome}`); }); - printSuccess(thisCommand, item, () => { - console.log(`⏰ 已延后提醒: ${item.title} · ${item.nextReminderAt} · v${item.version}`); + }) +); + +const inspirationFlowSettings = inspirationFlow + .command("settings") + .description("查看 Flow 设置;使用 settings set 提交完整的版本化设置。") + .addHelpText( + "after", + `\n示例:\n $ el inspiration flow settings --json\n $ el inspiration flow settings set --help\n` + ); + +withJson(inspirationFlowSettings).action( + action(async (thisCommand) => { + const settings = await api(`${inspirationApiPrefix}/flow/settings`); + printSuccess(thisCommand, settings, () => { + const value = settings as any; + console.log(`Flow: ${value.enabled ? "已启用" : "未启用"} · v${value.version}`); + console.log(` 周期 ${value.intervalMinutes} 分钟 · 冷却 ${value.cooldownMinutes} 分钟 · 每日上限 ${value.dailyLimit}`); + console.log(` 安静时间 ${formatMinute(value.quietStartMinute)}–${formatMinute(value.quietEndMinute)}`); }); }) ); withJson( - schedule - .command("done <id>") - .description("显式完成 scheduled 或 active 日程;不会修改任何 Core record。") - .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + inspirationFlowSettings + .command("set") + .description("提交完整 FlowSettingsUpdate;所有选项必填,版本冲突返回 409。") + .requiredOption("--version <n>", "当前 settings version") + .requiredOption("--enabled <boolean>", "是否启用定时 Flow: true | false") + .requiredOption("--interval-minutes <n>", "定时检查间隔分钟数") + .requiredOption("--quiet-start <HH:mm>", "安静时间开始,HH:mm") + .requiredOption("--quiet-end <HH:mm>", "安静时间结束,HH:mm;开始晚于结束表示跨夜") + .requiredOption("--cooldown-minutes <n>", "同一灵感冷却分钟数") + .requiredOption("--daily-limit <n>", "每日浮现上限") + .requiredOption("--default-snooze-minutes <n>", "later 默认稍后分钟数") + .requiredOption("--statuses <statuses>", "候选状态,逗号分隔: inbox | kept") + .requiredOption("--tags <tags>", "可选标签筛选,逗号分隔;传空字符串表示不限") + .requiredOption("--projects <projects>", "可选项目筛选,逗号分隔;传空字符串表示不限") .addHelpText( "after", ` 示例: - $ el schedule done <id> --expected-version 2 - $ el schedule done <id> --expected-version 2 --json + $ el inspiration flow settings set --version 1 --enabled true --interval-minutes 180 --quiet-start 22:00 --quiet-end 08:00 --cooldown-minutes 1440 --daily-limit 3 --default-snooze-minutes 120 --statuses inbox,kept --tags "" --projects "" --json ` ) ).action( - action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { - const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/complete`, { - expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + action(async (thisCommand, opts: { + version: string; + enabled: string; + intervalMinutes: string; + quietStart: string; + quietEnd: string; + cooldownMinutes: string; + dailyLimit: string; + defaultSnoozeMinutes: string; + statuses: string; + tags: string; + projects: string; + }) => { + const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value, false)); + if (statuses.length === 0) throw new CliUsageError("--statuses 至少包含 inbox 或 kept"); + const settings = await patch(`${inspirationApiPrefix}/flow/settings`, { + expectedVersion: inspirationInteger(opts.version, "--version", 1), + enabled: inspirationBoolean(opts.enabled, "--enabled"), + intervalMinutes: inspirationInteger(opts.intervalMinutes, "--interval-minutes", 1), + quietStartMinute: inspirationMinute(opts.quietStart, "--quiet-start"), + quietEndMinute: inspirationMinute(opts.quietEnd, "--quiet-end"), + cooldownMinutes: inspirationInteger(opts.cooldownMinutes, "--cooldown-minutes"), + dailyLimit: inspirationInteger(opts.dailyLimit, "--daily-limit", 1), + defaultSnoozeMinutes: inspirationInteger( + opts.defaultSnoozeMinutes, + "--default-snooze-minutes", + 1 + ), + statuses, + tags: splitCsv(opts.tags), + projects: splitCsv(opts.projects), }); - printSuccess(thisCommand, item, () => { - console.log(`✓ 已完成日程: ${item.title} [${item.id}] · v${item.version}`); + printSuccess(thisCommand, settings, () => { + console.log(`✓ Flow 设置已保存 v${(settings as any).version}`); }); }) ); withJson( - schedule - .command("cancel <id>") - .description("显式取消 scheduled 或 active 日程;忽略提醒本身不会取消。") - .requiredOption("--expected-version <n>", "当前版本,来自 list/show 返回的 version") + inspirationFlow + .command("deliveries") + .description("查看 Flow 投递 ledger;不包含灵感正文。") + .option("--limit <n>", "返回数量,范围 1–100", "20") + .option("--before <iso>", "surfacedAt 游标,ISO 8601 且包含时区") .addHelpText( "after", - ` -示例: - $ el schedule cancel <id> --expected-version 1 - $ el schedule cancel <id> --expected-version 1 --json -` + `\n示例:\n $ el inspiration flow deliveries --limit 20\n $ el inspiration flow deliveries --before 2026-08-24T12:00:00+08:00 --json\n` ) ).action( - action(async (thisCommand, id: string, opts: { expectedVersion: string }) => { - const item = await post<ScheduleCliItem>(`${scheduleItemPath(id)}/cancel`, { - expectedVersion: parseScheduleExpectedVersion(opts.expectedVersion), + action(async (thisCommand, opts: { limit: string; before?: string }) => { + const params = new URLSearchParams({ + limit: String(inspirationLimit(opts.limit, "--limit")), }); - printSuccess(thisCommand, item, () => { - console.log(`✗ 已取消日程: ${item.title} [${item.id}] · v${item.version}`); + if (opts.before) params.set("before", opts.before); + const result = await api(`${inspirationApiPrefix}/flow/deliveries?${params}`); + printSuccess(thisCommand, result, () => { + const deliveries = Array.isArray((result as any).deliveries) + ? (result as any).deliveries + : []; + if (deliveries.length === 0) { + console.log("暂无 Flow 投递"); + return; + } + for (const delivery of deliveries) { + console.log( + `${delivery.id}\tv${delivery.version}\t${delivery.status}\t${delivery.outcome ?? "-"}\t${delivery.surfacedAt}` + ); + } }); }) ); - // el screen [date] const screen = program .command("screen") From d384adb328bd39ac364fbe8a36e8a20af55bb768 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:32:39 +0800 Subject: [PATCH 19/33] ci: run PostgreSQL integration tests --- .github/workflows/ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6370133..0d003c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,20 @@ jobs: verify: name: verify runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: echolog_test + POSTGRES_USER: echolog + POSTGRES_PASSWORD: echolog + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U echolog -d echolog_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Check out EchoLog uses: actions/checkout@v5 @@ -48,6 +62,11 @@ jobs: - name: Test run: pnpm test + - name: Test PostgreSQL integrations + env: + ECHOLOG_TEST_DATABASE_URL: postgres://echolog:echolog@localhost:5432/echolog_test + run: pnpm exec tsx --test tests/**/*.integration.ts + - name: Typecheck run: pnpm typecheck From fdd22d96f4ae40ca67b031597903430d8edb55ce Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:43:27 +0800 Subject: [PATCH 20/33] fix(plugins): preserve abort and manifest validation semantics --- .../spec/backend/plugin-api-guidelines.md | 25 ++- docs/PLUGIN_API.md | 16 +- src/core/notifier.ts | 33 +++- src/core/plugins/host.ts | 14 +- tests/plugin-bundled-manifests.test.ts | 14 ++ tests/plugin-notification-host.test.ts | 136 +++++++++++++- tests/plugin-notifier.test.ts | 172 +++++++++++++++--- 7 files changed, 351 insertions(+), 59 deletions(-) create mode 100644 tests/plugin-bundled-manifests.test.ts diff --git a/.trellis/spec/backend/plugin-api-guidelines.md b/.trellis/spec/backend/plugin-api-guidelines.md index 5377216..e5554fd 100644 --- a/.trellis/spec/backend/plugin-api-guidelines.md +++ b/.trellis/spec/backend/plugin-api-guidelines.md @@ -20,9 +20,13 @@ enforcement mapping. Keep these layers synchronized in the same change: Authorization failures throw a structured `PluginError` with `PLUGIN_DEPENDENCY_MISSING` and identify the requesting plugin. Check permission -before revealing whether a privileged service is installed. Disabled plugin -lifecycle hooks never run; a bad service request during startup degrades only -that plugin and initialization continues with later plugins. +before revealing whether a privileged service is installed. Manifest and +API-version validation run for every definition before the enabled gate. Valid +disabled plugins remain `disabled`; malformed disabled plugins are +`enabled: false` and `degraded` with diagnostics. Neither form runs migration, +registration, start, jobs, or stop, and initialization continues with later +plugins. A bad service request during enabled startup likewise degrades only +that plugin. ## Notification service pattern @@ -38,17 +42,22 @@ channel's outcome. Bound transport waits with a rejecting timeout race even when an underlying operation ignores `AbortSignal`; also honor the caller signal and remove timers -and listeners after settlement. Existing Core fire-and-forget callers may keep -a `void` compatibility wrapper, but plugin-facing calls use the result-bearing -primitive so delivery failures remain observable. +and listeners after settlement. Internal transport timeout is an operational +channel outcome and becomes `failed`. Caller cancellation represents uncertain +delivery ownership and MUST reject the whole service call with `AbortError`, +never ordinary failed data; downstream plugins must not terminalize durable +delivery state from it. Existing Core fire-and-forget callers may keep a `void` +compatibility wrapper, but plugin-facing calls use the result-bearing primitive +so delivery failures remain observable. ## Compatibility checklist - Treat v1 additions as additive: preserve existing generic service calls, bundled manifests, scheduler call signatures, routes, and lifecycle order. - Test permission denied and allowed paths, disabled hooks, degraded-plugin - isolation, per-channel outcomes, non-2xx responses, abort/timeout behavior, - and legacy caller compatibility. + isolation, actual bundled manifests, per-channel outcomes, non-2xx responses, + caller-abort rejection versus internal-timeout results, and legacy caller + compatibility. - Run the SDK test/build before root tests when workspace packages have not yet produced their `dist` type entrypoints; finish with root `test`, `typecheck`, and `build`. diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index b5fceb7..c724801 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -84,6 +84,12 @@ validation, migration or startup failure changes that plugin to `degraded` and MUST NOT prevent later plugins or Core from starting. Shutdown occurs in reverse registry order with a five-second timeout. +Manifest and API-version validation runs for every bundled definition before +the enabled gate. A valid disabled plugin remains `disabled`; a malformed +disabled plugin is reported as `enabled: false`, `degraded`, with structured +diagnostic metadata. It still MUST NOT run migration, registration, start, jobs, +or stop, and its failure MUST NOT block later plugins or Core startup. + Configuration changes take effect after daemon restart. Runtime hot install, enable, disable and unload are not supported in v1. @@ -128,7 +134,15 @@ const result = await sendNotification( ``` The request contains only `title` and `message`; the optional second argument -is an `AbortSignal`. The result reports the Core channels independently: +is the caller's `AbortSignal`. Caller cancellation rejects the service call with +an error named `AbortError`; it MUST NOT resolve a channel result, because a +Host timeout or shutdown cannot know whether an in-flight transport accepted +the notification. Plugins MUST leave durable delivery state retryable when they +receive this cancellation. + +Core-owned transport timeouts and operational channel errors are different: +they resolve the normal result with that channel marked `failed`. Without caller +cancellation, the result reports the Core channels independently: ```ts { diff --git a/src/core/notifier.ts b/src/core/notifier.ts index 016ff78..1dc55d3 100644 --- a/src/core/notifier.ts +++ b/src/core/notifier.ts @@ -33,9 +33,16 @@ export interface NotificationDependencies { timeoutMs?: number; } -class DeliveryAbortedError extends Error {} class DeliveryTimeoutError extends Error {} +class CallerAbortError extends Error { + override name = "AbortError"; +} + +function callerAbortError(): CallerAbortError { + return new CallerAbortError("Notification delivery aborted"); +} + function failed(error: string): PluginNotificationChannelResult { return { status: "failed", @@ -50,9 +57,6 @@ function failureResult( if (error instanceof DeliveryTimeoutError) { return failed(`${channel} notification timed out`); } - if (error instanceof DeliveryAbortedError) { - return failed(`${channel} notification aborted`); - } return failed(`${channel} notification failed`); } @@ -79,7 +83,7 @@ function runBounded<T>( }; const onAbort = () => { controller.abort(); - finish({ error: new DeliveryAbortedError() }); + finish({ error: callerAbortError() }); }; if (callerSignal?.aborted) { @@ -92,12 +96,21 @@ function runBounded<T>( finish({ error: new DeliveryTimeoutError() }); }, timeoutMs); - Promise.resolve() - .then(() => operation(controller.signal)) - .then( + queueMicrotask(() => { + if (settled) return; + + let delivery: Promise<T>; + try { + delivery = operation(controller.signal); + } catch (error) { + finish({ error }); + return; + } + delivery.then( (value) => finish({ value }), (error: unknown) => finish({ error }) ); + }); }); } @@ -131,6 +144,7 @@ async function sendMac( ); return { status: "sent" }; } catch (error) { + if (error instanceof CallerAbortError) throw error; return failureResult("mac", error); } } @@ -162,6 +176,7 @@ async function sendNtfy( } return { status: "sent" }; } catch (error) { + if (error instanceof CallerAbortError) throw error; return failureResult("ntfy", error); } } @@ -171,6 +186,8 @@ export async function sendNotification( signal?: AbortSignal, dependencies: NotificationDependencies = {} ): Promise<PluginNotificationResult> { + if (signal?.aborted) throw callerAbortError(); + let config: Config; try { config = (dependencies.loadConfig ?? loadConfig)(); diff --git a/src/core/plugins/host.ts b/src/core/plugins/host.ts index e255746..e19638f 100644 --- a/src/core/plugins/host.ts +++ b/src/core/plugins/host.ts @@ -195,7 +195,6 @@ export class PluginHost { async initialize(): Promise<void> { for (const runtime of this.runtimes.values()) { - if (!runtime.info.enabled) continue; try { this.setState(runtime, "validating"); const manifestErrors = validatePluginManifest(runtime.definition.manifest); @@ -207,11 +206,18 @@ export class PluginHost { "validating" ); } + if (manifestErrors.length > 0) { + throw new Error(manifestErrors.join("; ")); + } + if (!runtime.info.enabled) { + this.setState(runtime, "disabled"); + continue; + } + const configErrors = runtime.definition.validateConfig?.( runtime.context.config ) ?? []; - const errors = [...manifestErrors, ...configErrors]; - if (errors.length > 0) throw new Error(errors.join("; ")); + if (configErrors.length > 0) throw new Error(configErrors.join("; ")); this.setState(runtime, "migrating"); await this.options.migrationRunner( @@ -233,7 +239,7 @@ export class PluginHost { async stop(): Promise<void> { const runtimes = [...this.runtimes.values()].reverse(); for (const runtime of runtimes) { - if (runtime.info.state === "disabled") continue; + if (!runtime.info.enabled) continue; this.setState(runtime, "stopping"); this.stopJobs(runtime); try { diff --git a/tests/plugin-bundled-manifests.test.ts b/tests/plugin-bundled-manifests.test.ts new file mode 100644 index 0000000..3df03dd --- /dev/null +++ b/tests/plugin-bundled-manifests.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validatePluginManifest } from "@echolog/plugin-sdk"; +import { bundledPlugins } from "../src/core/plugins/registry.js"; + +test("every bundled plugin definition has a valid runtime manifest", () => { + const errors = bundledPlugins.flatMap((definition) => + validatePluginManifest(definition.manifest).map( + (error) => `${definition.manifest.id}: ${error}` + ) + ); + + assert.deepEqual(errors, []); +}); diff --git a/tests/plugin-notification-host.test.ts b/tests/plugin-notification-host.test.ts index d221e6b..f39e468 100644 --- a/tests/plugin-notification-host.test.ts +++ b/tests/plugin-notification-host.test.ts @@ -121,35 +121,153 @@ test("returns the Core-owned send function to a permitted plugin", async () => { }); test("does not run notification lifecycle hooks for a disabled plugin", async () => { - let hooks = 0; + const lifecycle = { + migrations: 0, + register: 0, + start: 0, + jobs: 0, + stop: 0, + }; let serviceCalls = 0; - const pluginHost = host( - [{ + const pluginHost = new PluginHost({ + definitions: [{ manifest: manifest("notification-disabled", ["notifications:send"]), defaultEnabled: false, register(context) { - hooks++; + lifecycle.register++; context.service("notifications.send"); + context.registerJob({ + id: "disabled-job", + intervalMs: 1, + async run() { + lifecycle.jobs++; + }, + }); }, start(context) { - hooks++; + lifecycle.start++; context.service("notifications.send"); }, + stop() { + lifecycle.stop++; + }, }], - { + logger, + migrationRunner: async () => { + lifecycle.migrations++; + }, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + services: { "notifications.send": async () => { serviceCalls++; }, - } - ); + }, + }); await pluginHost.initialize(); + await pluginHost.stop(); assert.equal(pluginHost.list()[0]?.state, "disabled"); - assert.equal(hooks, 0); + assert.deepEqual(lifecycle, { + migrations: 0, + register: 0, + start: 0, + jobs: 0, + stop: 0, + }); assert.equal(serviceCalls, 0); }); +test("degrades an invalid disabled manifest without running lifecycle hooks", async () => { + const lifecycle = { + invalidMigrations: 0, + invalidRegister: 0, + invalidStart: 0, + invalidJobs: 0, + invalidStop: 0, + healthyMigrations: 0, + healthyStart: 0, + healthyStop: 0, + }; + const invalidDefinition: PluginDefinition = { + manifest: { + ...manifest("notification-invalid-disabled"), + permissions: [ + "notifications:unsupported" as PluginManifest["permissions"][number], + ], + }, + defaultEnabled: false, + register(context) { + lifecycle.invalidRegister++; + context.registerJob({ + id: "invalid-disabled-job", + intervalMs: 1, + async run() { + lifecycle.invalidJobs++; + }, + }); + }, + start() { + lifecycle.invalidStart++; + }, + stop() { + lifecycle.invalidStop++; + }, + }; + const pluginHost = new PluginHost({ + definitions: [ + invalidDefinition, + { + manifest: manifest("notification-healthy-after-invalid"), + defaultEnabled: true, + start() { + lifecycle.healthyStart++; + }, + stop() { + lifecycle.healthyStop++; + }, + }, + ], + logger, + migrationRunner: async (pluginId) => { + if (pluginId === "notification-invalid-disabled") { + lifecycle.invalidMigrations++; + } else if (pluginId === "notification-healthy-after-invalid") { + lifecycle.healthyMigrations++; + } + }, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + }); + + await pluginHost.initialize(); + + const plugins = Object.fromEntries( + pluginHost.list().map((plugin) => [plugin.id, plugin]) + ); + const invalid = plugins["notification-invalid-disabled"]; + assert.equal(invalid?.enabled, false); + assert.equal(invalid?.state, "degraded"); + assert.equal(invalid?.error?.code, "PLUGIN_DEGRADED"); + assert.match(invalid?.error?.message ?? "", /unsupported/); + assert.equal(invalid?.failureCount, 1); + assert.ok(invalid?.lastErrorAt); + assert.equal(plugins["notification-healthy-after-invalid"]?.state, "ready"); + assert.deepEqual(lifecycle, { + invalidMigrations: 0, + invalidRegister: 0, + invalidStart: 0, + invalidJobs: 0, + invalidStop: 0, + healthyMigrations: 1, + healthyStart: 1, + healthyStop: 0, + }); + + await pluginHost.stop(); + assert.equal(lifecycle.invalidStop, 0); + assert.equal(lifecycle.healthyStop, 1); +}); + test("isolates an unavailable notification service from later plugins", async () => { let healthyStarted = false; const pluginHost = host([ diff --git a/tests/plugin-notifier.test.ts b/tests/plugin-notifier.test.ts index 0a420ba..8fd45dc 100644 --- a/tests/plugin-notifier.test.ts +++ b/tests/plugin-notifier.test.ts @@ -4,7 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import type { Config } from "../src/core/config.js"; -import { notify, sendNotification } from "../src/core/notifier.js"; +import { + notify, + sendNotification, + type MacNotify, + type NotificationFetch, +} from "../src/core/notifier.js"; function config( notifications: Partial<Config["notifications"]> & { @@ -43,6 +48,73 @@ function config( const request = { title: "Reminder", message: "Private notification body" }; +function signalPoint(): { reached: Promise<void>; release: () => void } { + let release!: () => void; + const reached = new Promise<void>((resolve) => { + release = resolve; + }); + return { reached, release }; +} + +function assertAbortError(error: unknown): boolean { + assert.ok(error instanceof Error); + assert.equal(error.name, "AbortError"); + return true; +} + +test("rejects a pre-aborted call before configuration or channel short circuits", async () => { + const controller = new AbortController(); + controller.abort(); + let configCalls = 0; + let macCalls = 0; + let fetchCalls = 0; + + await assert.rejects( + sendNotification(request, controller.signal, { + loadConfig: () => { + configCalls++; + return config({ enabled: false }); + }, + macNotify: () => { + macCalls++; + }, + fetch: async () => { + fetchCalls++; + return new Response(null, { status: 200 }); + }, + }), + assertAbortError + ); + + assert.equal(configCalls, 0); + assert.equal(macCalls, 0); + assert.equal(fetchCalls, 0); +}); + +test("does not dispatch queued transports after an immediate caller abort", async () => { + const controller = new AbortController(); + let macCalls = 0; + let fetchCalls = 0; + const delivery = sendNotification(request, controller.signal, { + loadConfig: () => config(), + macNotify: () => { + macCalls++; + }, + fetch: async () => { + fetchCalls++; + return new Response(null, { status: 200 }); + }, + timeoutMs: 10_000, + }); + const rejected = assert.rejects(delivery, assertAbortError); + + controller.abort(); + await rejected; + + assert.equal(macCalls, 0); + assert.equal(fetchCalls, 0); +}); + test("reports both channels disabled when notifications are globally disabled", async () => { let macCalls = 0; let fetchCalls = 0; @@ -116,37 +188,43 @@ test("reports mac callback success and failure", async (t) => { }); }); -test("bounds a non-cooperative mac delivery with an internal timeout", async () => { - const startedAt = Date.now(); - const result = await sendNotification(request, undefined, { - loadConfig: () => config({ ntfy: { enabled: false } }), - macNotify: () => {}, - timeoutMs: 10, - }); - - assert.equal(result.channels.mac.status, "failed"); - assert.ok(Date.now() - startedAt < 1_000, "delivery timeout must be bounded"); - assert.match( - result.channels.mac.status === "failed" ? result.channels.mac.error : "", - /timed out/i - ); -}); - -test("honors a caller-provided abort signal for mac delivery", async () => { +test("distinguishes caller abort from timeout for the same non-cooperative mac transport", async () => { + const starts = [signalPoint(), signalPoint()]; + const callbacks: Array<(error: Error | null) => void> = []; + const macNotify: MacNotify = (_options, callback) => { + const callIndex = callbacks.push(callback) - 1; + starts[callIndex]?.release(); + }; const controller = new AbortController(); const delivery = sendNotification(request, controller.signal, { loadConfig: () => config({ ntfy: { enabled: false } }), - macNotify: () => {}, + macNotify, timeoutMs: 10_000, }); + const rejected = assert.rejects(delivery, assertAbortError); + + await starts[0].reached; controller.abort(); + await rejected; + assert.equal(callbacks.length, 1); + assert.doesNotThrow(() => callbacks[0](null)); + + const timedDelivery = sendNotification(request, undefined, { + loadConfig: () => config({ ntfy: { enabled: false } }), + macNotify, + timeoutMs: 10, + }); + await starts[1].reached; + const result = await timedDelivery; - const result = await delivery; assert.equal(result.channels.mac.status, "failed"); assert.match( result.channels.mac.status === "failed" ? result.channels.mac.error : "", - /abort/i + /timed out/i ); + assert.deepEqual(result.channels.ntfy, { status: "disabled" }); + assert.equal(callbacks.length, 2); + assert.doesNotThrow(() => callbacks[1](null)); }); test("reports ntfy success, non-2xx, and network failures", async (t) => { @@ -197,25 +275,61 @@ test("reports ntfy success, non-2xx, and network failures", async (t) => { ); } }); + + await t.test("transport-supplied AbortError remains an operational failure", async () => { + const result = await sendNotification(request, undefined, { + loadConfig: ntfyOnly, + fetch: async () => { + throw new DOMException("transport cancelled itself", "AbortError"); + }, + }); + + assert.equal(result.channels.ntfy.status, "failed"); + assert.match( + result.channels.ntfy.status === "failed" + ? result.channels.ntfy.error + : "", + /ntfy notification failed/i + ); + }); }); -test("aborts an in-flight ntfy transport when its delivery times out", async () => { - let transportSignal: AbortSignal | undefined; - const result = await sendNotification(request, undefined, { +test("distinguishes caller abort from timeout for the same non-cooperative ntfy transport", async () => { + const starts = [signalPoint(), signalPoint()]; + const transportSignals: Array<AbortSignal | undefined> = []; + const fetch: NotificationFetch = async (_input, init) => { + const callIndex = transportSignals.push(init?.signal ?? undefined) - 1; + starts[callIndex]?.release(); + return new Promise<Pick<Response, "ok" | "status">>(() => {}); + }; + const controller = new AbortController(); + const delivery = sendNotification(request, controller.signal, { loadConfig: () => config({ mac: false }), - fetch: async (_input, init) => { - transportSignal = init?.signal ?? undefined; - return new Promise<Pick<Response, "ok" | "status">>(() => {}); - }, + fetch, + timeoutMs: 10_000, + }); + const rejected = assert.rejects(delivery, assertAbortError); + + await starts[0].reached; + controller.abort(); + await rejected; + assert.equal(transportSignals[0]?.aborted, true); + + const timedDelivery = sendNotification(request, undefined, { + loadConfig: () => config({ mac: false }), + fetch, timeoutMs: 10, }); + await starts[1].reached; + const result = await timedDelivery; - assert.equal(transportSignal?.aborted, true); + assert.equal(transportSignals[1]?.aborted, true); assert.equal(result.channels.ntfy.status, "failed"); assert.match( result.channels.ntfy.status === "failed" ? result.channels.ntfy.error : "", /timed out/i ); + assert.deepEqual(result.channels.mac, { status: "disabled" }); }); test("keeps channel results independent when one delivery fails", async () => { From 4c5f55fef0552fc7552e4b2bbbce83013e1f1522 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:43:43 +0800 Subject: [PATCH 21/33] chore(trellis): archive notification review fixes --- .../check.jsonl | 7 ++ .../design.md | 65 +++++++++++++++++++ .../implement.jsonl | 7 ++ .../implement.md | 26 ++++++++ .../prd.md | 55 ++++++++++++++++ .../research/pr36-review-analysis.md | 29 +++++++++ .../task.json | 30 +++++++++ 7 files changed, 219 insertions(+) create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/design.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/research/pr36-review-analysis.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/task.json diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/check.jsonl new file mode 100644 index 0000000..11521bc --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/check.jsonl @@ -0,0 +1,7 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/backend/index.md", "reason": "Full backend quality check"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Review diagnostic and AbortError semantics"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Review timeout cleanup and lifecycle isolation"} +{"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Review named-service compatibility"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Review cross-layer contract consistency"} +{"file": ".trellis/tasks/08-24-plugin-notification-review-fixes/research/pr36-review-analysis.md", "reason": "Review against verified blocker analysis"} diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/design.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/design.md new file mode 100644 index 0000000..fde07a5 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/design.md @@ -0,0 +1,65 @@ +# PR 36 notification and Host review-fix design + +## Cancellation taxonomy + +`runBounded` has two cancellation sources: + +1. **Caller abort** — ownership lies outside the notification transport (for + example Plugin Host job timeout or daemon stop). It aborts the transport and + rejects with a normalized `DOMException(..., "AbortError")`. +2. **Internal transport timeout** — Core bounded a delivery that did not settle. + It aborts the transport and rejects internally with `DeliveryTimeoutError`. + +`sendMac` and `sendNtfy` rethrow `AbortError` unchanged. They convert internal +timeout and operational transport errors into the existing channel result +union. `sendNotification` uses `Promise.all`, so caller abort rejects the whole +service call and prevents a downstream plugin from finalizing an uncertain +delivery. Without caller abort, mac and ntfy still settle independently. + +The entry point checks an already-aborted caller signal before configuration or +disabled-channel short circuits. Timer and event-listener cleanup happens once +in the shared settle path. `AbortController.abort()` propagates to fetch; macOS +native delivery cannot be cancelled after dispatch, but the caller settles and +late callbacks are ignored. + +## Host initialization state machine + +Every runtime enters `validating` during `initialize`, regardless of its +configured enabled flag. The Host performs runtime manifest and API-version +validation first: + +```text +definition + -> validating + -> invalid: degraded (enabled false/true preserved; no lifecycle) + -> valid + disabled: disabled (no lifecycle) + -> valid + enabled: config validation -> migrating -> starting -> ready +``` + +The disabled gate remains before config validation, migration, registration, +start, and job scheduling. Shutdown skips every runtime with `enabled === false` +so an invalid disabled definition cannot accidentally invoke `stop` after being +marked degraded. Enabled plugins retain the existing best-effort stop behavior +for partial startup cleanup. + +`PLUGIN_DEGRADED` remains the diagnostic code for malformed manifests; the +existing runtime info exposes the validation message, failure count, and last +error timestamp. Core health is not blocked and the loop continues. + +## Contract tests + +Abort tests use externally controlled non-cooperative mac and ntfy transports: +the same transport shape rejects with `AbortError` under caller abort but +resolves structured failures under the internal timeout. This avoids tests that +merely assert an implementation-specific error class. + +Host tests use `defaultEnabled: false` with an unsupported permission cast at +the definition boundary, count migration/register/start/stop calls, and include +a later healthy plugin. A registry test iterates the real `bundledPlugins` +array and calls `validatePluginManifest` on each actual manifest. + +## Compatibility + +No SDK request/result types or permission names change. Core scheduler calls +remain signal-free fire-and-forget. Valid disabled plugin state is unchanged. +Only previously invalid manifests and caller-cancelled sends change semantics. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.jsonl new file mode 100644 index 0000000..4b87f0b --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.jsonl @@ -0,0 +1,7 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/backend/index.md", "reason": "Backend quality entry point"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Structured degraded and cancellation errors"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Timeout, cleanup, lifecycle, and verification rules"} +{"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Named-service notification and disabled lifecycle contract"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Notifier-Host-plugin boundary synchronization"} +{"file": ".trellis/tasks/08-24-plugin-notification-review-fixes/research/pr36-review-analysis.md", "reason": "Verified PR #36 root-cause analysis"} diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.md new file mode 100644 index 0000000..e936525 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/implement.md @@ -0,0 +1,26 @@ +# PR 36 notification and Host review-fix implementation plan + +1. Notification implementation agent + - Update `src/core/notifier.ts` to normalize/rethrow caller `AbortError` while + retaining structured internal timeout/transport results. + - Replace the old caller-abort-is-failed assertion with controlled caller + abort and internal timeout tests in `tests/plugin-notifier.test.ts`. + +2. Host implementation agent + - Move manifest/API validation before the enabled gate in + `src/core/plugins/host.ts` and keep lifecycle disabled afterward. + - Extend Host tests for invalid disabled isolation and create a registry-wide + actual-manifest validation test. + +3. Independent reviewer + - Read the task/spec context, wait for both implementation streams, inspect + the complete diff without editing it, and run focused tests/typecheck. + - Report verified findings by severity; implementation agents fix their own + files if needed. + +4. Main-agent integration + - Synchronize `docs/PLUGIN_API.md` and the backend plugin API spec. + - Run focused tests, then full `pnpm test`, `pnpm typecheck`, `pnpm build`, + and `git diff --check`. + - Mark acceptance, append a fix commit, archive the Trellis repair task, and + record the session. Do not push or merge. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/prd.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/prd.md new file mode 100644 index 0000000..6a9313c --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/prd.md @@ -0,0 +1,55 @@ +# PR 36 notification host review fixes + +## Goal + +Resolve the PR #36 notification cancellation and disabled-manifest validation +blockers without changing the additive Plugin API v1 request/result shape or +rewriting branch history. + +## Requirements + +- Caller cancellation and Core-owned transport timeout MUST be distinguishable. +- A caller-provided `AbortSignal` abort MUST reject `notifications.send` with an + error whose name is `AbortError`; it MUST NOT resolve channel `failed` data. +- Internal delivery timeout, mac callback error, ntfy non-2xx, and ntfy network + error MUST continue resolving bounded, non-sensitive per-channel `failed` + results, while unaffected channels retain independent outcomes. +- Abort and timeout paths MUST abort the transport signal and remove timers and + caller listeners after settlement; late mac callbacks MUST be harmless. +- `validatePluginManifest` and API-version validation MUST run for every bundled + definition, including disabled definitions, before the Host decides whether + to migrate/register/start it. +- A valid disabled plugin remains `disabled`. An invalid disabled plugin becomes + `enabled: false, state: degraded` with diagnostic error metadata, runs no + migration/register/start/stop lifecycle, and does not block later plugins or + Core startup. +- Automated tests MUST exercise the real disabled Host path and validate every + actual definition exported by `bundledPlugins`. +- Plugin API documentation and Trellis backend guidance MUST describe the new + cancellation and disabled-manifest semantics. +- Changes MUST be appended on `codex/plugin-notification-service`; do not amend, + rebase, push, or merge `main`. + +## Acceptance Criteria + +- [x] Pre-aborted and in-flight caller aborts reject with `AbortError` and do not + return a terminalizable `PluginNotificationResult`. +- [x] Equivalent non-cooperative transports under the internal timeout resolve + mac/ntfy `failed` results, proving timeout is not conflated with caller + abort. +- [x] Normal mixed-channel success/failure remains independent. +- [x] A disabled manifest with an unsupported permission is reported degraded, + has a manifest-validation error, runs no lifecycle or migration, and does + not prevent a following healthy plugin from becoming ready. +- [x] A valid disabled manifest remains disabled and runs no lifecycle. +- [x] Every current `bundledPlugins` manifest passes runtime validation. +- [x] `pnpm test`, `pnpm typecheck`, `pnpm build`, and `git diff --check` pass. +- [x] An independent reviewer reports no unresolved P0/P1/P2 finding in scope. +- [x] A new fix commit is appended without rewriting existing commits. + +## Notes + +- Review source: PR #36 at reviewed commit `b751338a0a`; this repair task handles + only the delegated Core notifier and Host blockers, not the separate + inspiration flow review comments. +- Original task: `.trellis/tasks/archive/2026-08/08-24-plugin-notification-service/`. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/research/pr36-review-analysis.md b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/research/pr36-review-analysis.md new file mode 100644 index 0000000..834f420 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/research/pr36-review-analysis.md @@ -0,0 +1,29 @@ +# PR #36 Core notification and Host review analysis + +## P1 caller abort + +`runBounded` currently produces a private `DeliveryAbortedError` for caller +abort. Both `sendMac` and `sendNtfy` catch it and `failureResult` converts it to +ordinary channel `{status:"failed"}` data. Consequently `sendNotification` +resolves, and downstream schedule/inspiration code can finalize an uncertain +delivery after Host job timeout or shutdown cancellation. + +The fix must keep `DeliveryTimeoutError` internal and result-bearing while +normalizing caller cancellation to `AbortError` and rethrowing it through both +channel functions and the aggregate service. + +## P2 disabled manifest validation + +`PluginHost.initialize` currently executes `if (!runtime.info.enabled) continue` +before `validatePluginManifest`, so an unsupported permission on a disabled +definition appears healthy and disabled. Moving manifest/API validation first +will make it degraded, but shutdown must also skip by `enabled === false`; +otherwise the new degraded state would cause `stop` to run for a plugin whose +lifecycle never started. + +## Registry scope + +This branch currently registers screen-time and tmux-status. The test must +iterate the exported registry rather than hard-code IDs, so the PR #36 +integration branch automatically validates its additional inspiration and +schedule manifests after the fix is incorporated. diff --git a/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/task.json b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/task.json new file mode 100644 index 0000000..6fa817f --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-plugin-notification-review-fixes/task.json @@ -0,0 +1,30 @@ +{ + "id": "plugin-notification-review-fixes", + "name": "plugin-notification-review-fixes", + "title": "PR 36 notification host review fixes", + "description": "", + "status": "completed", + "dev_type": null, + "scope": "backend,cross-layer", + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-24", + "completedAt": "2026-08-24", + "branch": "codex/plugin-notification-service", + "base_branch": "main", + "worktree_path": null, + "commit": "fdd22d9", + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "PR #36 Codex review repair; follows archived plugin-notification-service task.", + "meta": { + "pr": 36, + "pr_url": "https://github.com/CubePlus1/echolog/pull/36", + "reviewed_commit": "b751338a0aa300602716f82fcaf8a075ee012a3e" + } +} From c0712aef685af0efe322532322e8506e52c8f3c4 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:44:08 +0800 Subject: [PATCH 22/33] chore(trellis): record notification review fixes --- .trellis/workspace/codex/index.md | 5 ++-- .trellis/workspace/codex/journal-1.md | 42 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/codex/index.md b/.trellis/workspace/codex/index.md index 45adb72..a877bd9 100644 --- a/.trellis/workspace/codex/index.md +++ b/.trellis/workspace/codex/index.md @@ -8,7 +8,7 @@ <!-- @@@auto:current-status --> - **Active File**: `journal-1.md` -- **Total Sessions**: 1 +- **Total Sessions**: 2 - **Last Active**: 2026-08-24 <!-- @@@/auto:current-status --> @@ -19,7 +19,7 @@ <!-- @@@auto:active-documents --> | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~41 | Active | +| `journal-1.md` | ~82 | Active | <!-- @@@/auto:active-documents --> --- @@ -29,6 +29,7 @@ <!-- @@@auto:session-history --> | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 2 | 2026-08-24 | PR 36 notification and Host review fixes | `fdd22d9`, `4c5f55f` | `codex/plugin-notification-service` | | 1 | 2026-08-24 | Bundled Plugin API v1 notification service | `29fe6c3`, `3bd3f38` | `codex/plugin-notification-service` | <!-- @@@/auto:session-history --> diff --git a/.trellis/workspace/codex/journal-1.md b/.trellis/workspace/codex/journal-1.md index f13b060..b77a476 100644 --- a/.trellis/workspace/codex/journal-1.md +++ b/.trellis/workspace/codex/journal-1.md @@ -46,3 +46,45 @@ Added notifications.send with notifications:send permission enforcement, structu ### Next Steps - Schedule plugin #31 and inspiration plugins #33/#34 can adopt the new service contract independently. + + +## Session 2: PR 36 notification and Host review fixes + +**Date**: 2026-08-24 +**Task**: PR 36 notification and Host review fixes +**Branch**: `codex/plugin-notification-service` + +### Summary + +Fixed caller AbortError propagation and queued-transport cancellation race; validated disabled manifests before lifecycle gating; added dynamic bundled-manifest coverage; independent re-review found no unresolved P0/P1/P2; full tests/typecheck/build passed. + +### Main Changes + +- Propagated caller cancellation as `AbortError` while keeping internal transport timeouts result-bearing. +- Prevented queued mac/ntfy transports from dispatching after an immediate caller abort. +- Validated every manifest/API version before the Host disabled gate and isolated invalid disabled definitions. +- Added non-tautological abort/timeout tests, disabled lifecycle counters, and dynamic bundled-registry validation. +- Synchronized Plugin API documentation and backend guidance with the repaired semantics. + +### Git Commits + +| Hash | Message | +|------|---------| +| `fdd22d9` | (see git log) | +| `4c5f55f` | (see git log) | + +### Testing + +- [OK] `pnpm test` — 117 tests, 116 passed, 1 platform-conditional skip +- [OK] `pnpm typecheck` +- [OK] `pnpm build` +- [OK] `git diff --check` +- [OK] Independent re-review — no unresolved P0/P1/P2 in scope + +### Status + +[OK] **Completed** + +### Next Steps + +- Integrate `fdd22d9` into the PR #36 branch and request Codex review on the resulting latest commit. From c4bc6869f95914f48c829f5bcee817fafe68a375 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:49:02 +0800 Subject: [PATCH 23/33] fix(schedule): retain claims after job abort --- .trellis/spec/backend/database-guidelines.md | 24 +- .trellis/spec/backend/quality-guidelines.md | 5 + .../check.jsonl | 4 + .../design.md | 46 ++ .../implement.jsonl | 4 + .../implement.md | 13 + .../prd.md | 41 ++ .../task.json | 26 ++ docs/PLUGIN_API.md | 6 + plugins/schedule/README.md | 5 + plugins/schedule/src/reminders.ts | 14 +- tests/schedule-job.test.ts | 440 ++++++++++++++++++ tests/schedule.test.ts | 28 +- 13 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 .trellis/tasks/08-24-schedule-abort-terminal-boundary/check.jsonl create mode 100644 .trellis/tasks/08-24-schedule-abort-terminal-boundary/design.md create mode 100644 .trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.jsonl create mode 100644 .trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.md create mode 100644 .trellis/tasks/08-24-schedule-abort-terminal-boundary/prd.md create mode 100644 .trellis/tasks/08-24-schedule-abort-terminal-boundary/task.json create mode 100644 tests/schedule-job.test.ts diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index dc7bd75..2d08a59 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -65,6 +65,11 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl reminder instant creates a new key. - Delivery never performs an implicit domain transition. Confirm/start, complete, cancel, and snooze remain explicit versioned mutations. +- An external delivery continuation may terminalize `claimed` only while its + caller signal is still authoritative. After every awaited send and + immediately before `claimed -> sent|failed`, recheck the signal. Caller + abort or `AbortError` retains `claimed`; ordinary channel/service failure + still writes `failed`. ### 4. Validation & Error Matrix @@ -75,7 +80,7 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl | Bare local datetime / invalid IANA zone | 400 `{error}` | | Duplicate or restarted poll | Existing ledger excludes the exact instant; no send | | Host notification failure | Record bounded failure; do not change item state | -| Job abort/timeout | Honor the signal, release Host running state, retain claim | +| Job abort/timeout/stop | Rethrow before finalization, release Host running state, retain `claimed` | ### 5. Good/Base/Bad Cases @@ -85,6 +90,8 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl explicit confirmation. - Bad: query the oldest 100 due items first, then dedupe in application code. The same ledgered rows occupy every batch and permanently starve row 101. +- Bad: catch an aborted notification, write `failed`, and only then inspect + the signal. A timed-out late continuation has already corrupted diagnosis. ### 6. Tests Required @@ -99,6 +106,10 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl `(item_id, reminder_at)` lookup index. - Assert failed/ignored delivery does not modify status, confirmed timestamp, or create a Core record. +- Through the real Host scheduler, timeout/stop an in-flight controlled send, + settle it late as success/AbortError/ordinary rejection, and assert the exact + ledger stays `claimed`, terminal counters stay zero, and later intervals + dedupe without another send. ### 7. Wrong vs Correct @@ -129,6 +140,17 @@ CREATE INDEX idx_schedule_reminder_deliveries_item_reminder ON schedule_reminder_deliveries(item_id, reminder_at); ``` +```typescript +// Wrong: timeout/stop may have aborted while send was pending. +const result = await send(request, signal); +await finishReminder(result); + +// Correct: a late continuation must prove it still has write authority. +const result = await send(request, signal); +signal.throwIfAborted(); +await finishReminder(result); +``` + ## Common Mistakes - 忘了迁移与 schema.ts 双写,跑起来才发现列不存在 diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index 80ccd8f..c2a38ef 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -32,6 +32,11 @@ - `setInterval` 回调必须防重入(`sampling` 标志)+ 整体 try-catch(单轮失败不杀循环,连败 N 次才告警) - 超时不能只调用 `AbortController.abort()`:数据库写入等操作可能忽略 signal。必须同时 race 一个会 reject 的 timeout,确保 `running` 在 `finally` 中释放,后续轮次可以继续。 +- Host timeout/stop 释放 `running` 后,旧 continuation 即失去 terminal-write + authority。每次等待外部 I/O(尤其通知发送)返回后、进入持久化前都必须复查 + caller signal;`signal.aborted` 或 `AbortError` 直接上抛,不能记作普通 + operational failure。测试须让受控 promise 在真实 Host timeout/stop 后迟到 + resolve/reject,并断言无 terminal write。 - 崩溃容忍:片段开启即 INSERT,周期 UPDATE(60s),`stopTracker` 收尾在 `lastSeenAt` 而非 `new Date()` - 采样断档检测(`now - lastSampleAt > 3×间隔`)兜住睡眠/合盖,在最后活跃时刻收尾 diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/check.jsonl b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/check.jsonl new file mode 100644 index 0000000..bb4b66b --- /dev/null +++ b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Audit timeout/stop/late continuation behavior."} +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Audit retained-claim and terminal ledger invariants."} +{"file":".trellis/spec/backend/error-handling.md","reason":"Audit AbortError versus normal failure classification."} +{"file":".trellis/tasks/archive/2026-08/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Verify the fix preserves original plugin boundaries."} diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/design.md b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/design.md new file mode 100644 index 0000000..b899738 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/design.md @@ -0,0 +1,46 @@ +# Schedule abort terminalization design + +## Boundary contract + +The ledger state `claimed` means delivery outcome is still uncertain. Only an +unaborted caller may transition it to `sent` or `failed`. Host timeout and +daemon stop own the job's `AbortSignal`; after either event, a continuation is +late and loses authority to persist a terminal outcome. + +## Control flow + +```text +claim reminder + -> await notifications.send(request, callerSignal) + -> callerSignal.throwIfAborted() + -> validate channel result + -> callerSignal.throwIfAborted() + -> finishReminder(sent|failed) + +catch error + -> if callerSignal.aborted || error.name === "AbortError": rethrow + -> callerSignal.throwIfAborted() + -> finishReminder(failed) +``` + +The second catch-path signal check closes an abort that happens while +classifying a normal error. The final signal checks guard every persistence +entry point. The database write itself remains the existing atomic +`claimed -> sent|failed` update. + +## Host regression shape + +Use the real `PluginHost` scheduler with a test plugin whose job calls +`pollDueReminders` and whose notification promise is externally controlled. +For timeout, wait until Host aborts and releases the run, then resolve/reject the +old promise and prove no terminal write. For stop, abort via `host.stop()`, +settle the late promise, and prove the same retained claim. Tests must observe +the ledger seam, not a duplicated copy of the production control flow. + +## File ownership + +- Implementation agent: `plugins/schedule/src/reminders.ts` only. +- Regression agent: `tests/schedule.test.ts` and + `tests/schedule-job.test.ts` only. +- Main agent: Trellis/spec/PR tracking, integration, validation, commits. +- Independent check agent: read-only review after integration. diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.jsonl b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.jsonl new file mode 100644 index 0000000..0c0be71 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Host job timeout/non-reentry and abort boundary conventions."} +{"file":".trellis/spec/backend/database-guidelines.md","reason":"Claimed-to-terminal persistence contract and at-most-once ledger semantics."} +{"file":".trellis/spec/backend/error-handling.md","reason":"Differentiate caller abort from normal operational failure."} +{"file":".trellis/tasks/archive/2026-08/08-24-schedule-plugin/research/plugin-patterns.md","reason":"Original notification dependency and Schedule ledger decisions."} diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.md b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.md new file mode 100644 index 0000000..d6db2bf --- /dev/null +++ b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.md @@ -0,0 +1,13 @@ +# Schedule abort terminalization implementation plan + +1. Freeze task context and disjoint implementation/test ownership. +2. In parallel: + - implementation agent adds abort classification and pre-finalize guards; + - regression agent updates the abort unit contract and adds real Host + timeout/stop/late-resolution tests. +3. Main integrates and inspects every await-to-persistence boundary. +4. Run focused Schedule/Host tests, explicit PostgreSQL integration, full + `pnpm test`, `pnpm typecheck`, `pnpm build`, and diff-check. +5. Dispatch an independent read-only reviewer; resolve any P0-P2 and re-review. +6. Update durable specs/PR tracking, append implementation and archive commits, + record the session, and leave push/merge to the owning integration workflow. diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/prd.md b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/prd.md new file mode 100644 index 0000000..f8533c8 --- /dev/null +++ b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/prd.md @@ -0,0 +1,41 @@ +# Fix Schedule abort terminalization boundary + +## Goal + +Resolve PR #36's Schedule P1 so a Host timeout or daemon stop cannot let a +late notification continuation terminalize an uncertain reminder claim. + +## Requirements + +- In `pollDueReminders`, a caught error MUST be rethrown before + `finishReminder` when the caller signal is aborted or the error is an + `AbortError`. +- After `notifications.send` settles, the caller signal MUST be checked before + validating/finalizing a sent or failed result. +- Every sent/failed persistence boundary MUST be preceded by an abort check; + caller abort retains the ledger in `claimed` for diagnosis. +- Caller abort MUST NOT modify the Schedule item, increment terminal counters, + or record a misleading failed/sent delivery. +- Normal notification channel failures and non-abort service errors MUST still + terminalize deterministically as `failed`. +- Tests MUST replace the old abort-to-failed expectation and exercise actual + PluginHost timeout, stop, and late resolve/reject behavior. +- The fix MUST consume the formal SDK caller `AbortSignal` contract only; it + MUST NOT copy or modify Core notifier implementation. + +## Acceptance Criteria + +- [x] Pre-abort, in-flight caller abort, AbortError rejection, late success + after timeout, and late rejection after stop all retain `claimed`. +- [x] Internal channel `failed` and ordinary non-abort throw still finalize + `failed`. +- [x] A real PluginHost timeout releases `job.running`, records + `PLUGIN_TIMEOUT`, and the late Schedule continuation performs no + terminal ledger write. +- [x] A real PluginHost stop aborts the job and the late continuation performs + no terminal ledger write. +- [x] Focused tests, PostgreSQL integration, `pnpm test`, `pnpm typecheck`, + `pnpm build`, and diff-check pass. +- [x] Independent review of the final diff reports no P0-P2. +- [ ] Commits are appended on `codex/schedule-plugin`; no history rewrite, + push, or merge occurs. diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/task.json b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/task.json new file mode 100644 index 0000000..80938da --- /dev/null +++ b/.trellis/tasks/08-24-schedule-abort-terminal-boundary/task.json @@ -0,0 +1,26 @@ +{ + "id": "schedule-abort-terminal-boundary", + "name": "schedule-abort-terminal-boundary", + "title": "Fix Schedule abort terminalization boundary", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": "backend", + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": "codex/schedule-plugin", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index c9758cd..793861d 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -231,6 +231,12 @@ not later than now. Month, week, and day views project the same mutations require `expectedVersion`, and each reminder instant is claimed by a unique ledger dedupe key before delivery. +If a Host job timeout or daemon stop aborts the caller signal, a late +notification continuation MUST NOT terminalize the reminder. Schedule retains +the ledger as `claimed`; only an unaborted caller may persist `sent` or +`failed`. Internal channel failures returned while the caller remains active +still terminalize as `failed`. + ## Compatibility policy API v1 changes are additive. A breaking SDK, lifecycle or manifest change diff --git a/plugins/schedule/README.md b/plugins/schedule/README.md index e0fecff..bc952e9 100644 --- a/plugins/schedule/README.md +++ b/plugins/schedule/README.md @@ -31,6 +31,11 @@ optional `AbortSignal`, and per-channel `sent | disabled | failed` results. It does not import the Core notifier or access notification configuration. Missing service capability degrades only this plugin. +Host timeout or daemon stop aborts the caller signal. If notification delivery +settles after that abort, Schedule retains the ledger as `claimed` for +diagnosis and performs no late `sent`/`failed` write. Normal channel failure +while the caller remains active is still terminalized as `failed`. + Canonical routes: - `GET|POST /api/plugins/schedule/items` diff --git a/plugins/schedule/src/reminders.ts b/plugins/schedule/src/reminders.ts index 3e1bdae..d43b28f 100644 --- a/plugins/schedule/src/reminders.ts +++ b/plugins/schedule/src/reminders.ts @@ -36,6 +36,10 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + function validChannelResult(value: unknown): value is NotificationChannelResult { if (!value || typeof value !== "object") return false; const result = value as Record<string, unknown>; @@ -123,21 +127,25 @@ export async function pollDueReminders( let result: NotificationSendResult; try { signal.throwIfAborted(); - result = validateNotificationResult(await send({ + const sendResult = await send({ title: `Schedule reminder: ${reminder.item.title}`, message: notificationMessage(reminder), - }, signal)); + }, signal); + signal.throwIfAborted(); + result = validateNotificationResult(sendResult); } catch (error) { + if (signal.aborted || isAbortError(error)) throw error; + signal.throwIfAborted(); await store.finishReminder(claimed.id, { status: "failed", channelResults: null, failure: errorMessage(error), }, new Date()); summary.failed++; - if (signal.aborted) throw error; continue; } const outcome = resultOutcome(result); + signal.throwIfAborted(); await store.finishReminder(claimed.id, { status: outcome.status, channelResults: result.channels, diff --git a/tests/schedule-job.test.ts b/tests/schedule-job.test.ts new file mode 100644 index 0000000..3387101 --- /dev/null +++ b/tests/schedule-job.test.ts @@ -0,0 +1,440 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + PLUGIN_API_VERSION, + type PluginDefinition, + type PluginLogger, + type PluginManifest, +} from "@echolog/plugin-sdk"; +import { + pollDueReminders, + type ReminderPollResult, + type ReminderStore, +} from "../plugins/schedule/src/reminders.js"; +import { + reminderDedupeKey, + type DueReminder, +} from "../plugins/schedule/src/store.js"; +import type { + NotificationSend, + NotificationSendResult, + ReminderDelivery, + ScheduleItem, +} from "../plugins/schedule/src/types.js"; +import { PluginHost } from "../src/core/plugins/host.js"; + +const logger: PluginLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +const sentResult: NotificationSendResult = { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, +}; + +function scheduleItem(): ScheduleItem { + return { + id: "schedule_host_001", + title: "Host boundary reminder", + description: null, + scheduledStartAt: "2026-08-24T02:00:00.000Z", + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + status: "scheduled", + nextReminderAt: "2026-08-24T02:00:00.000Z", + confirmedStartAt: null, + completedAt: null, + cancelledAt: null, + version: 1, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:00.000Z", + awaitingConfirmation: true, + }; +} + +function manifest(id: string): PluginManifest { + return { + manifestVersion: 1, + id, + version: "1.0.0", + apiVersion: PLUGIN_API_VERSION, + displayName: id, + description: `${id} Schedule job boundary test plugin`, + entries: {}, + capabilities: [], + permissions: ["notifications:send"], + requires: { coreApi: "^1.0.0" }, + }; +} + +interface Deferred<T> { + promise: Promise<T>; + resolve(value: T): void; + reject(error: unknown): void; + readonly settled: boolean; +} + +function deferred<T>(): Deferred<T> { + let settled = false; + let resolvePromise!: (value: T) => void; + let rejectPromise!: (error: unknown) => void; + const promise = new Promise<T>((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + resolve(value) { + if (settled) return; + settled = true; + resolvePromise(value); + }, + reject(error) { + if (settled) return; + settled = true; + rejectPromise(error); + }, + get settled() { + return settled; + }, + }; +} + +async function waitFor( + predicate: () => boolean, + message: string, + timeoutMs = 1_000 +): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) assert.fail(message); + await new Promise<void>((resolve) => setTimeout(resolve, 2)); + } +} + +class ObservedReminderStore implements ReminderStore { + readonly item = scheduleItem(); + readonly reminderAt = new Date(this.item.nextReminderAt!); + readonly deliveries = new Map<string, ReminderDelivery>(); + readonly finishCalls: Array<{ + id: string; + status: "sent" | "failed"; + channelResults: NotificationSendResult["channels"] | null; + failure: string | null; + }> = []; + readonly terminalCounters = { sent: 0, failed: 0 }; + dueCalls = 0; + claimCalls = 0; + + async dueReminders(): Promise<DueReminder[]> { + this.dueCalls++; + return [{ item: this.item, reminderAt: this.reminderAt }]; + } + + async claimReminder( + itemId: string, + reminderAt: Date + ): Promise<ReminderDelivery | null> { + this.claimCalls++; + const dedupeKey = reminderDedupeKey(itemId, reminderAt); + if (this.deliveries.has(dedupeKey)) return null; + const delivery: ReminderDelivery = { + id: "delivery_host_001", + dedupeKey, + itemId, + reminderAt: reminderAt.toISOString(), + attemptedAt: new Date().toISOString(), + completedAt: null, + status: "claimed", + channelResults: null, + failure: null, + }; + this.deliveries.set(dedupeKey, delivery); + return delivery; + } + + async finishReminder( + id: string, + input: { + status: "sent" | "failed"; + channelResults: NotificationSendResult["channels"] | null; + failure: string | null; + } + ): Promise<ReminderDelivery> { + this.finishCalls.push({ id, ...input }); + this.terminalCounters[input.status]++; + const delivery = [...this.deliveries.values()].find((entry) => entry.id === id); + if (!delivery || delivery.status !== "claimed") { + throw new Error(`delivery ${id} is not claimable`); + } + Object.assign(delivery, input, { completedAt: new Date().toISOString() }); + return delivery; + } + + claimedDelivery(): ReminderDelivery { + const delivery = [...this.deliveries.values()][0]; + assert.ok(delivery, "the first Host run should claim the due reminder"); + return delivery; + } +} + +interface JobHarness { + host: PluginHost; + store: ObservedReminderStore; + getRuns(): number; + getCompletedRunIds(): readonly number[]; + getSummaries(): ReadonlyArray<{ runId: number; result: ReminderPollResult }>; +} + +function jobHarness( + id: string, + send: NotificationSend, + options: { intervalMs: number; timeoutMs: number } +): JobHarness { + const store = new ObservedReminderStore(); + let runs = 0; + const completedRunIds: number[] = []; + const summaries: Array<{ runId: number; result: ReminderPollResult }> = []; + const definition: PluginDefinition = { + manifest: manifest(id), + defaultEnabled: true, + register(context) { + const notificationSend = context.service<NotificationSend>("notifications.send"); + context.registerJob({ + id: "reminder-poll", + ...options, + async run(signal) { + const runId = ++runs; + try { + summaries.push({ + runId, + result: await pollDueReminders(store, notificationSend, signal), + }); + } finally { + completedRunIds.push(runId); + } + }, + }); + }, + }; + const host = new PluginHost({ + definitions: [definition], + logger, + migrationRunner: async () => {}, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + services: { "notifications.send": send }, + }); + return { + host, + store, + getRuns: () => runs, + getCompletedRunIds: () => completedRunIds, + getSummaries: () => summaries, + }; +} + +function assertRetainedClaim(store: ObservedReminderStore): void { + const delivery = store.claimedDelivery(); + assert.equal(delivery.status, "claimed"); + assert.equal(delivery.completedAt, null); + assert.equal(delivery.channelResults, null); + assert.equal(delivery.failure, null); + assert.deepEqual(store.finishCalls, []); + assert.deepEqual(store.terminalCounters, { sent: 0, failed: 0 }); + assert.equal(store.item.status, "scheduled"); + assert.equal(store.item.version, 1); +} + +const timeoutSettlements: Array<{ + name: string; + settle(pending: Deferred<NotificationSendResult>): void; +}> = [ + { + name: "late success", + settle: (pending) => pending.resolve(sentResult), + }, + { + name: "late AbortError rejection", + settle: (pending) => pending.reject(Object.assign( + new Error("notification caller aborted"), + { name: "AbortError" } + )), + }, + { + name: "late non-abort rejection", + settle: (pending) => pending.reject(new Error("provider rejected after timeout")), + }, +]; + +for (const [index, scenario] of timeoutSettlements.entries()) { + test(`PluginHost timeout retains the Schedule claim after ${scenario.name}`, { + timeout: 3_000, + }, async () => { + const pending = deferred<NotificationSendResult>(); + let sendCalls = 0; + const sendSignals: AbortSignal[] = []; + const harness = jobHarness( + `schedule-timeout-${index}`, + async (_request, sendSignal) => { + sendCalls++; + assert.ok(sendSignal, "Schedule must forward the Host caller signal"); + sendSignals.push(sendSignal); + return pending.promise; + }, + { intervalMs: 8, timeoutMs: 20 } + ); + + try { + await harness.host.initialize(); + await waitFor(() => sendCalls === 1, "the first Host run did not reach send"); + await waitFor( + () => + harness.host.list()[0]?.error?.code === "PLUGIN_TIMEOUT" && + harness.getRuns() >= 2 && + harness.getSummaries().some(({ runId }) => runId > 1), + "Host did not release the timed-out run for a subsequent interval" + ); + + assert.equal(sendCalls, 1, "the retained exact claim must not be sent twice"); + assert.equal(sendSignals[0]?.aborted, true); + assert.ok(harness.store.claimCalls >= 2, "a later Host run should observe dedupe"); + assertRetainedClaim(harness.store); + + scenario.settle(pending); + await waitFor( + () => harness.getCompletedRunIds().includes(1), + "the late first continuation did not settle" + ); + + assert.equal(harness.host.list()[0]?.error?.code, "PLUGIN_TIMEOUT"); + assert.equal(sendCalls, 1); + assertRetainedClaim(harness.store); + } finally { + if (!pending.settled) pending.resolve(sentResult); + await harness.host.stop(); + } + }); +} + +const stopSettlements: Array<{ + name: string; + settle(pending: Deferred<NotificationSendResult>): void; +}> = [ + { + name: "late success", + settle: (pending) => pending.resolve(sentResult), + }, + { + name: "late rejection", + settle: (pending) => pending.reject(new Error("provider rejected after stop")), + }, +]; + +for (const [index, scenario] of stopSettlements.entries()) { + test(`PluginHost stop retains the Schedule claim after ${scenario.name}`, { + timeout: 3_000, + }, async () => { + const pending = deferred<NotificationSendResult>(); + let sendCalls = 0; + const sendSignals: AbortSignal[] = []; + let stopped = false; + const harness = jobHarness( + `schedule-stop-${index}`, + async (_request, sendSignal) => { + sendCalls++; + assert.ok(sendSignal, "Schedule must forward the Host caller signal"); + sendSignals.push(sendSignal); + return pending.promise; + }, + { intervalMs: 8, timeoutMs: 1_000 } + ); + + try { + await harness.host.initialize(); + await waitFor(() => sendCalls === 1, "the Host run did not reach send before stop"); + assertRetainedClaim(harness.store); + + await harness.host.stop(); + stopped = true; + assert.equal(harness.host.list()[0]?.state, "stopping"); + assert.equal(sendSignals[0]?.aborted, true); + + scenario.settle(pending); + await waitFor( + () => harness.getCompletedRunIds().includes(1), + "the stopped job's late continuation did not settle" + ); + + assert.equal(sendCalls, 1); + assertRetainedClaim(harness.store); + } finally { + if (!pending.settled) pending.resolve(sentResult); + if (!stopped) await harness.host.stop(); + } + }); +} + +const ordinaryFailures: Array<{ + name: string; + send: NotificationSend; + expectedFailure: RegExp; +}> = [ + { + name: "internal channel failure", + send: async () => ({ + channels: { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "ntfy offline" }, + }, + }), + expectedFailure: /mac: disabled; ntfy: ntfy offline/, + }, + { + name: "ordinary service rejection", + send: async () => { + throw new Error("notification provider unavailable"); + }, + expectedFailure: /notification provider unavailable/, + }, +]; + +for (const [index, scenario] of ordinaryFailures.entries()) { + test(`PluginHost Schedule job terminalizes ${scenario.name}`, { + timeout: 3_000, + }, async () => { + let sendCalls = 0; + const harness = jobHarness( + `schedule-failure-${index}`, + async (request, signal) => { + sendCalls++; + return scenario.send(request, signal); + }, + { intervalMs: 8, timeoutMs: 200 } + ); + + try { + await harness.host.initialize(); + await waitFor( + () => harness.store.finishCalls.length === 1, + "the ordinary failure did not reach the ReminderStore finish seam" + ); + + const delivery = harness.store.claimedDelivery(); + assert.equal(delivery.status, "failed"); + assert.ok(delivery.completedAt); + assert.match(delivery.failure ?? "", scenario.expectedFailure); + assert.deepEqual(harness.store.terminalCounters, { sent: 0, failed: 1 }); + assert.equal(sendCalls, 1); + assert.equal(harness.store.item.status, "scheduled"); + assert.equal(harness.store.item.version, 1); + } finally { + await harness.host.stop(); + } + }); +} diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts index c6c6b56..5cf4c2c 100644 --- a/tests/schedule.test.ts +++ b/tests/schedule.test.ts @@ -327,6 +327,10 @@ test("canonical routes preserve raw item arrays and structured conflicts", async interface ReminderState { due: DueReminder[]; deliveries: Map<string, ReminderDelivery>; + terminalWrites: Array<{ + id: string; + status: "sent" | "failed"; + }>; } class MemoryReminderStore { @@ -362,6 +366,7 @@ class MemoryReminderStore { failure: string | null; } ): Promise<ReminderDelivery> { + this.state.terminalWrites.push({ id, status: input.status }); const entry = [...this.state.deliveries.values()].find((value) => value.id === id); if (!entry || entry.status !== "claimed") throw new Error("not claimable"); Object.assign(entry, input, { completedAt: new Date().toISOString() }); @@ -374,6 +379,7 @@ function reminderState(): ReminderState { return { due: [{ item: scheduled, reminderAt: new Date(scheduled.nextReminderAt!) }], deliveries: new Map(), + terminalWrites: [], }; } @@ -421,7 +427,7 @@ test("reminder polling is at-most-once across repeat polls and store restarts", assert.equal(state.deliveries.size, 2); }); -test("reminder polling records disabled, failed, thrown, and aborted deliveries", async () => { +test("reminder polling terminalizes operational failures but retains caller-aborted claims", async () => { const disabled = reminderState(); await pollDueReminders(new MemoryReminderStore(disabled), async () => ({ channels: { @@ -433,6 +439,7 @@ test("reminder polling records disabled, failed, thrown, and aborted deliveries" assert.equal(disabledDelivery.status, "failed"); assert.match(disabledDelivery.failure!, /mac: disabled/); assert.match(disabledDelivery.failure!, /ntfy: offline/); + assert.deepEqual(disabled.terminalWrites.map(({ status }) => status), ["failed"]); const thrown = reminderState(); await pollDueReminders(new MemoryReminderStore(thrown), async () => { @@ -443,6 +450,7 @@ test("reminder polling records disabled, failed, thrown, and aborted deliveries" [...thrown.deliveries.values()][0]!.failure, "notification provider unavailable" ); + assert.deepEqual(thrown.terminalWrites.map(({ status }) => status), ["failed"]); const aborted = reminderState(); const controller = new AbortController(); @@ -454,7 +462,22 @@ test("reminder polling records disabled, failed, thrown, and aborted deliveries" }, controller.signal), (error) => error instanceof Error && error.name === "AbortError" ); - assert.equal([...aborted.deliveries.values()][0]!.status, "failed"); + const abortedDelivery = [...aborted.deliveries.values()][0]!; + assert.equal(abortedDelivery.status, "claimed"); + assert.equal(abortedDelivery.completedAt, null); + assert.deepEqual(aborted.terminalWrites, []); + + const abortError = reminderState(); + await assert.rejects( + pollDueReminders(new MemoryReminderStore(abortError), async () => { + throw Object.assign(new Error("notification caller aborted"), { + name: "AbortError", + }); + }, signal), + (error) => error instanceof Error && error.name === "AbortError" + ); + assert.equal([...abortError.deliveries.values()][0]!.status, "claimed"); + assert.deepEqual(abortError.terminalWrites, []); const preAborted = reminderState(); const before = new AbortController(); @@ -466,6 +489,7 @@ test("reminder polling records disabled, failed, thrown, and aborted deliveries" (error) => error instanceof Error && error.name === "AbortError" ); assert.equal(preAborted.deliveries.size, 0); + assert.deepEqual(preAborted.terminalWrites, []); }); test("Schedule registers one bounded Host job and cleans lifecycle state", async () => { From ea4119ddf0c317c0077ceac07f5db3f3850fa0a7 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Mon, 24 Aug 2026 03:49:26 +0800 Subject: [PATCH 24/33] chore(trellis): archive schedule abort fix --- .../08-24-schedule-abort-terminal-boundary/check.jsonl | 0 .../08-24-schedule-abort-terminal-boundary/design.md | 0 .../08-24-schedule-abort-terminal-boundary/implement.jsonl | 0 .../08-24-schedule-abort-terminal-boundary/implement.md | 0 .../2026-08}/08-24-schedule-abort-terminal-boundary/prd.md | 2 +- .../08-24-schedule-abort-terminal-boundary/task.json | 6 +++--- 6 files changed, 4 insertions(+), 4 deletions(-) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-abort-terminal-boundary/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-abort-terminal-boundary/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-abort-terminal-boundary/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-abort-terminal-boundary/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-abort-terminal-boundary/prd.md (96%) rename .trellis/tasks/{ => archive/2026-08}/08-24-schedule-abort-terminal-boundary/task.json (82%) diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/check.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-abort-terminal-boundary/check.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/check.jsonl diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/design.md b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/design.md similarity index 100% rename from .trellis/tasks/08-24-schedule-abort-terminal-boundary/design.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/design.md diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/implement.jsonl similarity index 100% rename from .trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/implement.jsonl diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.md b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/implement.md similarity index 100% rename from .trellis/tasks/08-24-schedule-abort-terminal-boundary/implement.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/implement.md diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/prd.md b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/prd.md similarity index 96% rename from .trellis/tasks/08-24-schedule-abort-terminal-boundary/prd.md rename to .trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/prd.md index f8533c8..c34e3c7 100644 --- a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/prd.md +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/prd.md @@ -37,5 +37,5 @@ late notification continuation terminalize an uncertain reminder claim. - [x] Focused tests, PostgreSQL integration, `pnpm test`, `pnpm typecheck`, `pnpm build`, and diff-check pass. - [x] Independent review of the final diff reports no P0-P2. -- [ ] Commits are appended on `codex/schedule-plugin`; no history rewrite, +- [x] Commits are appended on `codex/schedule-plugin`; no history rewrite, push, or merge occurs. diff --git a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/task.json b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/task.json similarity index 82% rename from .trellis/tasks/08-24-schedule-abort-terminal-boundary/task.json rename to .trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/task.json index 80938da..9da1376 100644 --- a/.trellis/tasks/08-24-schedule-abort-terminal-boundary/task.json +++ b/.trellis/tasks/archive/2026-08/08-24-schedule-abort-terminal-boundary/task.json @@ -3,7 +3,7 @@ "name": "schedule-abort-terminal-boundary", "title": "Fix Schedule abort terminalization boundary", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "backend", "package": null, @@ -11,11 +11,11 @@ "creator": "codex", "assignee": "codex", "createdAt": "2026-08-24", - "completedAt": null, + "completedAt": "2026-08-24", "branch": "codex/schedule-plugin", "base_branch": "main", "worktree_path": null, - "commit": null, + "commit": "c4bc6869f95914f48c829f5bcee817fafe68a375", "pr_url": null, "subtasks": [], "children": [], From f91b8c7ef5e1144a2ed5ea0f36a549f57f09fccc Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 10:00:42 +0800 Subject: [PATCH 25/33] fix(schedule): guard reminder claims after abort --- .trellis/spec/backend/database-guidelines.md | 7 + .trellis/spec/backend/quality-guidelines.md | 3 + docs/PLUGIN_API.md | 7 + plugins/schedule/README.md | 8 + plugins/schedule/src/index.ts | 3 + plugins/schedule/src/reminders.ts | 7 +- plugins/schedule/src/store.ts | 160 +++++++++-- tests/schedule-job.test.ts | 235 +++++++++++++++- tests/schedule.integration.ts | 266 ++++++++++++++++++- tests/schedule.test.ts | 127 ++++++++- 10 files changed, 790 insertions(+), 33 deletions(-) diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index 2d08a59..4f5e44e 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -70,6 +70,12 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl immediately before `claimed -> sent|failed`, recheck the signal. Caller abort or `AbortError` retains `claimed`; ordinary channel/service failure still writes `failed`. +- Reminder claim transactions accept the caller signal but bound database-lock + waiting with a separate internal transport timeout. Check the internal signal + before/after `FOR UPDATE`, before/after the ledger insert, and before the + transaction callback returns; caller abort and timeout must clean up their + timer/listener resources and a late lock release must roll back rather than + insert a claim. ### 4. Validation & Error Matrix @@ -81,6 +87,7 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl | Duplicate or restarted poll | Existing ledger excludes the exact instant; no send | | Host notification failure | Record bounded failure; do not change item state | | Job abort/timeout/stop | Rethrow before finalization, release Host running state, retain `claimed` | +| Blocked reminder claim timeout | Reject with distinct `SCHEDULE_CLAIM_TIMEOUT`, keep caller signal un-aborted, and prevent late ledger insert | ### 5. Good/Base/Bad Cases diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index c2a38ef..2d6447d 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -37,6 +37,9 @@ caller signal;`signal.aborted` 或 `AbortError` 直接上抛,不能记作普通 operational failure。测试须让受控 promise 在真实 Host timeout/stop 后迟到 resolve/reject,并断言无 terminal write。 +- 对可能等待数据库锁的后台操作,caller abort 与内部 transport timeout 必须使用 + 不同的 `AbortController`/错误类型;超时竞态必须同时拒绝外层等待、释放 timer 和 + listener,并在锁返回后、写入前复查内部 signal,禁止迟到 continuation 落库。 - 崩溃容忍:片段开启即 INSERT,周期 UPDATE(60s),`stopTracker` 收尾在 `lastSeenAt` 而非 `new Date()` - 采样断档检测(`now - lastSampleAt > 3×间隔`)兜住睡眠/合盖,在最后活跃时刻收尾 diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 793861d..324f43b 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -231,6 +231,13 @@ not later than now. Month, week, and day views project the same mutations require `expectedVersion`, and each reminder instant is claimed by a unique ledger dedupe key before delivery. +Claim acquisition forwards the Host caller `AbortSignal` through the +lock-and-insert transaction and uses a separate bounded transport timeout. A +caller abort or Host timeout/stop cannot produce a late ledger insert after a +blocked row lock resumes; the internal timeout is exposed as the distinct +`SCHEDULE_CLAIM_TIMEOUT` error and does not abort the caller signal. Timer and +abort-listener resources are cleaned up on success, caller abort, and timeout. + If a Host job timeout or daemon stop aborts the caller signal, a late notification continuation MUST NOT terminalize the reminder. Schedule retains the ledger as `claimed`; only an unaborted caller may persist `sent` or diff --git a/plugins/schedule/README.md b/plugins/schedule/README.md index bc952e9..9f2f57d 100644 --- a/plugins/schedule/README.md +++ b/plugins/schedule/README.md @@ -31,6 +31,14 @@ optional `AbortSignal`, and per-channel `sent | disabled | failed` results. It does not import the Core notifier or access notification configuration. Missing service capability degrades only this plugin. +Claiming is also abort-aware: the caller signal is forwarded through the +lock-and-insert transaction, while a separate bounded claim transport timeout +protects the scheduler from a blocked database lock. Caller aborts and Host +timeout/stop retain the reminder unclaimed (or, if a prior claim already +committed, as `claimed`); a late lock continuation cannot insert a ledger row. +The internal timeout is reported as a distinct `SCHEDULE_CLAIM_TIMEOUT` error +and does not abort the caller signal. + Host timeout or daemon stop aborts the caller signal. If notification delivery settles after that abort, Schedule retains the ledger as `claimed` for diagnosis and performs no late `sent`/`failed` write. Normal channel failure diff --git a/plugins/schedule/src/index.ts b/plugins/schedule/src/index.ts index 575e73f..0686853 100644 --- a/plugins/schedule/src/index.ts +++ b/plugins/schedule/src/index.ts @@ -175,12 +175,15 @@ export default schedulePlugin; export { pollDueReminders } from "./reminders.js"; export { createScheduleRoutes } from "./routes.js"; export { + SCHEDULE_CLAIM_TIMEOUT_MS, + ScheduleClaimTimeoutError, ScheduleConflictError, ScheduleNotFoundError, ScheduleStore, reminderDedupeKey, scheduleItemFromRow, } from "./store.js"; +export type { ScheduleStoreOptions } from "./store.js"; export type { NotificationSend, NotificationSendResult, diff --git a/plugins/schedule/src/reminders.ts b/plugins/schedule/src/reminders.ts index d43b28f..c36b446 100644 --- a/plugins/schedule/src/reminders.ts +++ b/plugins/schedule/src/reminders.ts @@ -11,7 +11,8 @@ export interface ReminderStore { claimReminder( itemId: string, reminderAt: Date, - attemptedAt?: Date + attemptedAt?: Date, + signal?: AbortSignal ): Promise<ReminderDelivery | null>; finishReminder( id: string, @@ -116,8 +117,10 @@ export async function pollDueReminders( const claimed = await store.claimReminder( reminder.item.id, reminder.reminderAt, - now + now, + signal ); + signal.throwIfAborted(); if (!claimed) { summary.deduplicated++; continue; diff --git a/plugins/schedule/src/store.ts b/plugins/schedule/src/store.ts index 7287d6f..1b61a2c 100644 --- a/plugins/schedule/src/store.ts +++ b/plugins/schedule/src/store.ts @@ -63,6 +63,100 @@ export interface DueReminder { reminderAt: Date; } +export const SCHEDULE_CLAIM_TIMEOUT_MS = 5_000; + +export class ScheduleClaimTimeoutError extends Error { + readonly code = "SCHEDULE_CLAIM_TIMEOUT" as const; + + constructor(public readonly timeoutMs: number) { + super(`Schedule reminder claim timed out after ${timeoutMs}ms`); + this.name = "ScheduleClaimTimeoutError"; + } +} + +export interface ScheduleStoreOptions { + claimTimeoutMs?: number; +} + +function callerAbortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); +} + +/** + * Bound the claim transport without conflating an internal timeout with the + * caller's AbortSignal. The operation is deliberately allowed to settle + * after the race, but its internal signal guards every transaction write so a + * late lock release cannot insert a claim. + */ +function runBoundedClaim<T>( + callerSignal: AbortSignal | undefined, + timeoutMs: number, + operation: (signal: AbortSignal) => Promise<T> +): Promise<T> { + const internalController = new AbortController(); + let timer: ReturnType<typeof setTimeout> | undefined; + let settled = false; + let removeCallerListener: (() => void) | undefined; + + const operationPromise = Promise.resolve().then(() => + operation(internalController.signal) + ); + // The operation may settle after the timeout/abort race. Attach a rejection + // handler immediately so that a late database error is never unhandled. + void operationPromise.catch(() => undefined); + + return new Promise<T>((resolve, reject) => { + const cleanup = () => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + removeCallerListener?.(); + removeCallerListener = undefined; + }; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; + const rejectCallerAbort = () => { + internalController.abort(); + settle(() => reject(callerAbortReason(callerSignal!))); + }; + + if (callerSignal) { + if (callerSignal.aborted) { + rejectCallerAbort(); + return; + } + const onAbort = () => rejectCallerAbort(); + callerSignal.addEventListener("abort", onAbort, { once: true }); + removeCallerListener = () => callerSignal.removeEventListener("abort", onAbort); + } + + timer = setTimeout(() => { + internalController.abort(); + settle(() => reject(new ScheduleClaimTimeoutError(timeoutMs))); + }, timeoutMs); + + void operationPromise.then( + (value) => { + if (callerSignal?.aborted) { + rejectCallerAbort(); + return; + } + settle(() => resolve(value)); + }, + (error: unknown) => { + if (callerSignal?.aborted) { + rejectCallerAbort(); + return; + } + settle(() => reject(error)); + } + ); + }); +} + function iso(value: Date | null): string | null { return value?.toISOString() ?? null; } @@ -114,10 +208,16 @@ export class ScheduleStore { private readonly sql; private readonly db; private closed = false; + private readonly claimTimeoutMs: number; - constructor(databaseUrl: string) { + constructor(databaseUrl: string, options: ScheduleStoreOptions = {}) { + const claimTimeoutMs = options.claimTimeoutMs ?? SCHEDULE_CLAIM_TIMEOUT_MS; + if (!Number.isInteger(claimTimeoutMs) || claimTimeoutMs <= 0) { + throw new Error("claimTimeoutMs must be a positive integer"); + } this.sql = postgres(databaseUrl); this.db = drizzle(this.sql); + this.claimTimeoutMs = claimTimeoutMs; } async close(): Promise<void> { @@ -341,35 +441,45 @@ export class ScheduleStore { async claimReminder( itemId: string, reminderAt: Date, - attemptedAt = new Date() + attemptedAt = new Date(), + callerSignal?: AbortSignal ): Promise<ReminderDelivery | null> { const id = nanoid(12); const dedupeKey = reminderDedupeKey(itemId, reminderAt); const reminderInstant = reminderAt.toISOString(); const attemptedInstant = attemptedAt.toISOString(); - const inserted = await this.sql.begin(async (transaction) => { - // Lock and re-check the item so a stale due-list snapshot cannot claim a - // reminder that was already confirmed, cancelled, or snoozed. - const eligible = await transaction<{ id: string }[]>` - SELECT id - FROM schedule_items - WHERE id = ${itemId} - AND status = 'scheduled' - AND next_reminder_at = ${reminderInstant} - FOR UPDATE - `; - if (!eligible[0]) return false; - const claimed = await transaction<{ id: string }[]>` - INSERT INTO schedule_reminder_deliveries ( - id, dedupe_key, item_id, reminder_at, attempted_at, status - ) VALUES ( - ${id}, ${dedupeKey}, ${itemId}, ${reminderInstant}, ${attemptedInstant}, 'claimed' - ) - ON CONFLICT (dedupe_key) DO NOTHING - RETURNING id - `; - return Boolean(claimed[0]); - }); + const inserted = await runBoundedClaim( + callerSignal, + this.claimTimeoutMs, + async (internalSignal) => this.sql.begin(async (transaction) => { + internalSignal.throwIfAborted(); + // Lock and re-check the item so a stale due-list snapshot cannot claim + // a reminder that was already confirmed, cancelled, or snoozed. + const eligible = await transaction<{ id: string }[]>` + SELECT id + FROM schedule_items + WHERE id = ${itemId} + AND status = 'scheduled' + AND next_reminder_at = ${reminderInstant} + FOR UPDATE + `; + internalSignal.throwIfAborted(); + if (!eligible[0]) return false; + internalSignal.throwIfAborted(); + const claimed = await transaction<{ id: string }[]>` + INSERT INTO schedule_reminder_deliveries ( + id, dedupe_key, item_id, reminder_at, attempted_at, status + ) VALUES ( + ${id}, ${dedupeKey}, ${itemId}, ${reminderInstant}, ${attemptedInstant}, 'claimed' + ) + ON CONFLICT (dedupe_key) DO NOTHING + RETURNING id + `; + internalSignal.throwIfAborted(); + return Boolean(claimed[0]); + }) + ); + if (callerSignal) callerSignal.throwIfAborted(); return inserted ? { id, dedupeKey, diff --git a/tests/schedule-job.test.ts b/tests/schedule-job.test.ts index 3387101..e5f1317 100644 --- a/tests/schedule-job.test.ts +++ b/tests/schedule-job.test.ts @@ -119,6 +119,10 @@ async function waitFor( } class ObservedReminderStore implements ReminderStore { + constructor( + private readonly claimPending?: Deferred<ReminderDelivery | null> + ) {} + readonly item = scheduleItem(); readonly reminderAt = new Date(this.item.nextReminderAt!); readonly deliveries = new Map<string, ReminderDelivery>(); @@ -131,6 +135,8 @@ class ObservedReminderStore implements ReminderStore { readonly terminalCounters = { sent: 0, failed: 0 }; dueCalls = 0; claimCalls = 0; + readonly claimSignals: AbortSignal[] = []; + private claimPendingUsed = false; async dueReminders(): Promise<DueReminder[]> { this.dueCalls++; @@ -139,9 +145,18 @@ class ObservedReminderStore implements ReminderStore { async claimReminder( itemId: string, - reminderAt: Date + reminderAt: Date, + _attemptedAt?: Date, + signal?: AbortSignal ): Promise<ReminderDelivery | null> { + signal?.throwIfAborted(); this.claimCalls++; + if (signal) this.claimSignals.push(signal); + if (this.claimPending) { + if (this.claimPendingUsed) return null; + this.claimPendingUsed = true; + return this.claimPending.promise; + } const dedupeKey = reminderDedupeKey(itemId, reminderAt); if (this.deliveries.has(dedupeKey)) return null; const delivery: ReminderDelivery = { @@ -195,9 +210,13 @@ interface JobHarness { function jobHarness( id: string, send: NotificationSend, - options: { intervalMs: number; timeoutMs: number } + options: { + intervalMs: number; + timeoutMs: number; + claimPending?: Deferred<ReminderDelivery | null>; + } ): JobHarness { - const store = new ObservedReminderStore(); + const store = new ObservedReminderStore(options.claimPending); let runs = 0; const completedRunIds: number[] = []; const summaries: Array<{ runId: number; result: ReminderPollResult }> = []; @@ -251,6 +270,45 @@ function assertRetainedClaim(store: ObservedReminderStore): void { assert.equal(store.item.version, 1); } +function assertNoClaim(store: ObservedReminderStore): void { + assert.deepEqual(store.deliveries, new Map()); + assert.deepEqual(store.finishCalls, []); + assert.deepEqual(store.terminalCounters, { sent: 0, failed: 0 }); + assert.equal(store.item.status, "scheduled"); + assert.equal(store.item.version, 1); +} + +function claimDelivery(store: ObservedReminderStore): ReminderDelivery { + return { + id: "late-claim-host", + dedupeKey: reminderDedupeKey(store.item.id, store.reminderAt), + itemId: store.item.id, + reminderAt: store.reminderAt.toISOString(), + attemptedAt: new Date().toISOString(), + completedAt: null, + status: "claimed", + channelResults: null, + failure: null, + }; +} + +function captureUnhandledRejections(): { + reasons: unknown[]; + stop(): void; +} { + const reasons: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + reasons.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + return { + reasons, + stop() { + process.off("unhandledRejection", onUnhandledRejection); + }, + }; +} + const timeoutSettlements: Array<{ name: string; settle(pending: Deferred<NotificationSendResult>): void; @@ -380,6 +438,177 @@ for (const [index, scenario] of stopSettlements.entries()) { }); } +test("PluginHost timeout isolates a late claim from Schedule persistence", { + timeout: 3_000, +}, async () => { + const pendingClaim = deferred<ReminderDelivery | null>(); + let sendCalls = 0; + const harness = jobHarness( + "schedule-claim-timeout", + async () => { + sendCalls++; + return sentResult; + }, + { intervalMs: 8, timeoutMs: 20, claimPending: pendingClaim } + ); + + try { + await harness.host.initialize(); + await waitFor( + () => harness.store.claimCalls === 1 && harness.store.claimSignals.length === 1, + "the first Host run did not reach claim" + ); + await waitFor( + () => + harness.host.list()[0]?.error?.code === "PLUGIN_TIMEOUT" && + harness.getRuns() >= 2, + "Host did not timeout and release the blocked claim" + ); + assert.equal(harness.store.claimSignals[0]?.aborted, true); + assert.equal(sendCalls, 0); + assertNoClaim(harness.store); + + pendingClaim.resolve(claimDelivery(harness.store)); + await waitFor( + () => harness.getCompletedRunIds().includes(1), + "the late claim continuation did not settle" + ); + assert.equal(sendCalls, 0); + assertNoClaim(harness.store); + } finally { + if (!pendingClaim.settled) pendingClaim.resolve(null); + await harness.host.stop(); + } +}); + +test("PluginHost stop isolates a late claim from Schedule persistence", { + timeout: 3_000, +}, async () => { + const pendingClaim = deferred<ReminderDelivery | null>(); + let sendCalls = 0; + const harness = jobHarness( + "schedule-claim-stop", + async () => { + sendCalls++; + return sentResult; + }, + { intervalMs: 8, timeoutMs: 1_000, claimPending: pendingClaim } + ); + + try { + await harness.host.initialize(); + await waitFor( + () => harness.store.claimCalls === 1 && harness.store.claimSignals.length === 1, + "the Host run did not reach claim before stop" + ); + await harness.host.stop(); + assert.equal(harness.store.claimSignals[0]?.aborted, true); + assert.equal(sendCalls, 0); + assertNoClaim(harness.store); + + pendingClaim.resolve(claimDelivery(harness.store)); + await waitFor( + () => harness.getCompletedRunIds().includes(1), + "the stopped claim continuation did not settle" + ); + assert.equal(sendCalls, 0); + assertNoClaim(harness.store); + } finally { + if (!pendingClaim.settled) pendingClaim.resolve(null); + // stop() is idempotent but avoid a second call when it already completed. + if (harness.host.list()[0]?.state !== "stopping") await harness.host.stop(); + } +}); + +test("PluginHost timeout contains a late ordinary claim rejection", { + timeout: 3_000, +}, async () => { + const pendingClaim = deferred<ReminderDelivery | null>(); + const unhandled = captureUnhandledRejections(); + let sendCalls = 0; + const harness = jobHarness( + "schedule-claim-timeout-rejection", + async () => { + sendCalls++; + return sentResult; + }, + { intervalMs: 8, timeoutMs: 20, claimPending: pendingClaim } + ); + + try { + await harness.host.initialize(); + await waitFor( + () => harness.store.claimCalls === 1 && harness.store.claimSignals.length === 1, + "the first Host run did not reach claim" + ); + await waitFor( + () => + harness.host.list()[0]?.error?.code === "PLUGIN_TIMEOUT" && + harness.getRuns() >= 2, + "Host did not timeout and release the rejected claim" + ); + + pendingClaim.reject(new Error("claim transport rejected after timeout")); + await waitFor( + () => harness.getCompletedRunIds().includes(1), + "the late rejected claim continuation did not settle" + ); + await new Promise<void>((resolve) => setImmediate(resolve)); + + assert.equal(harness.store.claimSignals[0]?.aborted, true); + assert.equal(sendCalls, 0); + assertNoClaim(harness.store); + assert.deepEqual(unhandled.reasons, []); + } finally { + if (!pendingClaim.settled) pendingClaim.resolve(null); + await harness.host.stop(); + unhandled.stop(); + } +}); + +test("PluginHost stop contains a late ordinary claim rejection", { + timeout: 3_000, +}, async () => { + const pendingClaim = deferred<ReminderDelivery | null>(); + const unhandled = captureUnhandledRejections(); + let sendCalls = 0; + let stopped = false; + const harness = jobHarness( + "schedule-claim-stop-rejection", + async () => { + sendCalls++; + return sentResult; + }, + { intervalMs: 8, timeoutMs: 1_000, claimPending: pendingClaim } + ); + + try { + await harness.host.initialize(); + await waitFor( + () => harness.store.claimCalls === 1 && harness.store.claimSignals.length === 1, + "the Host run did not reach claim before stop" + ); + await harness.host.stop(); + stopped = true; + + pendingClaim.reject(new Error("claim transport rejected after stop")); + await waitFor( + () => harness.getCompletedRunIds().includes(1), + "the stopped job's late rejected claim did not settle" + ); + await new Promise<void>((resolve) => setImmediate(resolve)); + + assert.equal(harness.store.claimSignals[0]?.aborted, true); + assert.equal(sendCalls, 0); + assertNoClaim(harness.store); + assert.deepEqual(unhandled.reasons, []); + } finally { + if (!pendingClaim.settled) pendingClaim.resolve(null); + if (!stopped) await harness.host.stop(); + unhandled.stop(); + } +}); + const ordinaryFailures: Array<{ name: string; send: NotificationSend; diff --git a/tests/schedule.integration.ts b/tests/schedule.integration.ts index 0f73fec..01e78b1 100644 --- a/tests/schedule.integration.ts +++ b/tests/schedule.integration.ts @@ -6,6 +6,7 @@ import postgres from "postgres"; import { schedulePlugin } from "../plugins/schedule/src/index.js"; import { pollDueReminders } from "../plugins/schedule/src/reminders.js"; import { + ScheduleClaimTimeoutError, ScheduleConflictError, ScheduleStore, } from "../plugins/schedule/src/store.js"; @@ -26,12 +27,122 @@ function quoteTestSchema(schema: string): string { return `"${schema}"`; } -function databaseUrlForSchema(databaseUrl: string, schema: string): string { +function databaseUrlForSchema( + databaseUrl: string, + schema: string, + applicationName?: string +): string { const url = new URL(databaseUrl); url.searchParams.set("options", `-c search_path=${schema}`); + if (applicationName) url.searchParams.set("application_name", applicationName); return url.toString(); } +interface HeldRowLock { + release(): void; + settled: Promise<void>; +} + +interface TrackedAbortController { + controller: AbortController; + listenerCounts(): { added: number; removed: number }; +} + +function trackedAbortController(): TrackedAbortController { + const controller = new AbortController(); + const signal = controller.signal; + const originalAdd = signal.addEventListener.bind(signal); + const originalRemove = signal.removeEventListener.bind(signal); + let added = 0; + let removed = 0; + + Object.defineProperties(signal, { + addEventListener: { + configurable: true, + value: (( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ) => { + if (type === "abort") added++; + return originalAdd(type, listener, options); + }) as AbortSignal["addEventListener"], + }, + removeEventListener: { + configurable: true, + value: (( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ) => { + if (type === "abort") removed++; + return originalRemove(type, listener, options); + }) as AbortSignal["removeEventListener"], + }, + }); + + return { + controller, + listenerCounts: () => ({ added, removed }), + }; +} + +async function holdScheduleItemLock( + connection: ReturnType<typeof postgres>, + itemId: string +): Promise<HeldRowLock> { + let releaseLock!: () => void; + let resolveLocked!: () => void; + let rejectLocked!: (error: unknown) => void; + let released = false; + const releaseRequested = new Promise<void>((resolve) => { + releaseLock = resolve; + }); + const locked = new Promise<void>((resolve, reject) => { + resolveLocked = resolve; + rejectLocked = reject; + }); + const settled = connection.begin(async (transaction) => { + await transaction` + SELECT id + FROM schedule_items + WHERE id = ${itemId} + FOR UPDATE + `; + resolveLocked(); + await releaseRequested; + }); + void settled.catch(rejectLocked); + await locked; + return { + release() { + if (released) return; + released = true; + releaseLock(); + }, + settled, + }; +} + +async function waitForBlockedApplication( + admin: ReturnType<typeof postgres>, + applicationName: string +): Promise<void> { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const [activity] = await admin<{ blocked: number }[]>` + SELECT COUNT(*)::integer AS blocked + FROM pg_stat_activity + WHERE application_name = ${applicationName} + AND state = 'active' + AND wait_event_type = 'Lock' + `; + if ((activity?.blocked ?? 0) > 0) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`claim connection ${applicationName} did not block on the row lock`); +} + const logger = { debug() {}, info() {}, @@ -46,6 +157,159 @@ test("schedule integration requires an explicit test database URL", () => { ); }); +test( + "Schedule rolls back blocked claims after internal timeout and caller abort", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => { + if (!testDatabaseUrl) return; + const schema = testSchemaName(); + const quotedSchema = quoteTestSchema(schema); + const scopedDatabaseUrl = databaseUrlForSchema(testDatabaseUrl, schema); + const admin = postgres(testDatabaseUrl, { max: 1 }); + const locker = postgres(scopedDatabaseUrl, { max: 1 }); + const timeoutApplication = `el_schedule_timeout_${randomUUID().slice(0, 12)}`; + const abortApplication = `el_schedule_abort_${randomUUID().slice(0, 12)}`; + const timeoutStore = new ScheduleStore( + databaseUrlForSchema(testDatabaseUrl, schema, timeoutApplication), + { claimTimeoutMs: 250 } + ); + const abortStore = new ScheduleStore( + databaseUrlForSchema(testDatabaseUrl, schema, abortApplication), + { claimTimeoutMs: 2_000 } + ); + const observerStore = new ScheduleStore(scopedDatabaseUrl); + const heldLocks: HeldRowLock[] = []; + let schemaCreated = false; + + try { + await admin.unsafe(`CREATE SCHEMA ${quotedSchema}`); + schemaCreated = true; + const migrationRunner = createPluginMigrationRunner(scopedDatabaseUrl); + await migrationRunner("schedule", schedulePlugin.migrations ?? []); + + const dueAt = new Date("2026-08-26T01:00:00Z"); + const attemptedAt = new Date("2026-08-26T02:00:00Z"); + const successItem = await observerStore.create({ + title: "Bounded claim success cleanup", + description: null, + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }); + const successCaller = trackedAbortController(); + assert.ok(await observerStore.claimReminder( + successItem.id, + dueAt, + attemptedAt, + successCaller.controller.signal + )); + assert.equal(successCaller.controller.signal.aborted, false); + assert.deepEqual( + successCaller.listenerCounts(), + { added: 1, removed: 1 }, + "a successful bounded claim must remove its caller abort listener" + ); + + const timeoutItem = await timeoutStore.create({ + title: "Blocked internal timeout", + description: null, + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }); + const timeoutLock = await holdScheduleItemLock(locker, timeoutItem.id); + heldLocks.push(timeoutLock); + const timeoutCaller = trackedAbortController(); + const timedOutClaim = timeoutStore.claimReminder( + timeoutItem.id, + dueAt, + attemptedAt, + timeoutCaller.controller.signal + ); + await waitForBlockedApplication(admin, timeoutApplication); + await assert.rejects(timedOutClaim, (error: unknown) => { + assert.ok(error instanceof ScheduleClaimTimeoutError); + assert.equal(error.code, "SCHEDULE_CLAIM_TIMEOUT"); + assert.equal(error.timeoutMs, 250); + return true; + }); + assert.equal( + timeoutCaller.controller.signal.aborted, + false, + "an internal transport timeout must not abort the caller signal" + ); + assert.deepEqual( + timeoutCaller.listenerCounts(), + { added: 1, removed: 1 }, + "an internal timeout must remove its caller abort listener" + ); + timeoutLock.release(); + await timeoutLock.settled; + await timeoutStore.close(); + assert.deepEqual( + await observerStore.listReminders({ itemId: timeoutItem.id, limit: 10 }), + [], + "the late transaction must roll back instead of inserting a claim" + ); + const unchangedAfterTimeout = await observerStore.get(timeoutItem.id); + assert.equal(unchangedAfterTimeout?.status, "scheduled"); + assert.equal(unchangedAfterTimeout?.version, 1); + + const abortItem = await observerStore.create({ + title: "Blocked caller abort", + description: null, + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }); + const abortLock = await holdScheduleItemLock(locker, abortItem.id); + heldLocks.push(abortLock); + const abortCaller = trackedAbortController(); + const abortedClaim = abortStore.claimReminder( + abortItem.id, + dueAt, + attemptedAt, + abortCaller.controller.signal + ); + await waitForBlockedApplication(admin, abortApplication); + const abortReason = new DOMException("host stopped", "AbortError"); + abortCaller.controller.abort(abortReason); + await assert.rejects(abortedClaim, (error: unknown) => error === abortReason); + assert.deepEqual( + abortCaller.listenerCounts(), + { added: 1, removed: 1 }, + "caller abort must remove the bounded claim listener" + ); + abortLock.release(); + await abortLock.settled; + await abortStore.close(); + assert.deepEqual( + await observerStore.listReminders({ itemId: abortItem.id, limit: 10 }), + [], + "caller abort must revoke the late transaction's claim authority" + ); + const unchangedAfterAbort = await observerStore.get(abortItem.id); + assert.equal(unchangedAfterAbort?.status, "scheduled"); + assert.equal(unchangedAfterAbort?.version, 1); + } finally { + for (const lock of heldLocks) lock.release(); + await Promise.allSettled(heldLocks.map(({ settled }) => settled)); + await abortStore.close(); + await timeoutStore.close(); + await observerStore.close(); + await locker.end(); + if (schemaCreated) await admin.unsafe(`DROP SCHEMA ${quotedSchema} CASCADE`); + await admin.end(); + } + } +); + test( "Schedule persists CAS transitions, range routes, and at-most-once reminder ledgers", { skip: !testDatabaseUrl, timeout: 30_000 }, diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts index 5cf4c2c..7d80243 100644 --- a/tests/schedule.test.ts +++ b/tests/schedule.test.ts @@ -11,7 +11,7 @@ import { SCHEDULE_REMINDER_JOB_TIMEOUT_MS, schedulePlugin, } from "../plugins/schedule/src/index.js"; -import { pollDueReminders } from "../plugins/schedule/src/reminders.js"; +import { pollDueReminders, type ReminderStore } from "../plugins/schedule/src/reminders.js"; import { createScheduleRoutes } from "../plugins/schedule/src/routes.js"; import { ScheduleConflictError, @@ -340,7 +340,13 @@ class MemoryReminderStore { return this.state.due; } - async claimReminder(itemId: string, reminderAt: Date): Promise<ReminderDelivery | null> { + async claimReminder( + itemId: string, + reminderAt: Date, + _attemptedAt?: Date, + signal?: AbortSignal + ): Promise<ReminderDelivery | null> { + signal?.throwIfAborted(); const dedupeKey = reminderDedupeKey(itemId, reminderAt); if (this.state.deliveries.has(dedupeKey)) return null; const delivery: ReminderDelivery = { @@ -383,6 +389,24 @@ function reminderState(): ReminderState { }; } +function deferred<T>(): { + promise: Promise<T>; + resolve(value: T): void; + reject(error: unknown): void; +} { + let resolvePromise!: (value: T) => void; + let rejectPromise!: (error: unknown) => void; + const promise = new Promise<T>((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + resolve: resolvePromise, + reject: rejectPromise, + }; +} + test("reminder polling is at-most-once across repeat polls and store restarts", async () => { const state = reminderState(); let sends = 0; @@ -492,6 +516,105 @@ test("reminder polling terminalizes operational failures but retains caller-abor assert.deepEqual(preAborted.terminalWrites, []); }); +test("reminder polling forwards caller abort through claim and ignores a late claim", async () => { + const dueState = reminderState(); + const pending = deferred<ReminderDelivery | null>(); + const controller = new AbortController(); + let claimSignal: AbortSignal | undefined; + let sends = 0; + let terminalWrites = 0; + const store: ReminderStore = { + async dueReminders() { + return dueState.due; + }, + async claimReminder(_itemId, _reminderAt, _attemptedAt, signal) { + claimSignal = signal; + return pending.promise; + }, + async finishReminder() { + terminalWrites++; + throw new Error("late claim must not finish a delivery"); + }, + }; + + const polling = pollDueReminders( + store, + async () => { + sends++; + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, + controller.signal + ); + + while (!claimSignal) { + await new Promise<void>((resolve) => setImmediate(resolve)); + } + assert.equal(claimSignal, controller.signal); + controller.abort(); + pending.resolve({ + id: "late-claim", + dedupeKey: "schedule:late", + itemId: dueState.due[0]!.item.id, + reminderAt: dueState.due[0]!.reminderAt.toISOString(), + attemptedAt: new Date().toISOString(), + completedAt: null, + status: "claimed", + channelResults: null, + failure: null, + }); + + await assert.rejects( + polling, + (error) => error instanceof Error && error.name === "AbortError" + ); + assert.equal(sends, 0); + assert.equal(terminalWrites, 0); +}); + +test("internal claim transport timeout is distinct from caller abort", async () => { + const timeout = Object.assign( + new Error("schedule reminder claim timed out"), + { name: "ScheduleClaimTimeoutError", code: "SCHEDULE_CLAIM_TIMEOUT" } + ); + const controller = new AbortController(); + const state = reminderState(); + let sends = 0; + const store: ReminderStore = { + async dueReminders() { + return state.due; + }, + async claimReminder() { + throw timeout; + }, + async finishReminder() { + throw new Error("claim timeout must not finish a delivery"); + }, + }; + await assert.rejects( + pollDueReminders( + store, + async () => { + sends++; + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, + controller.signal + ), + (error) => error === timeout + ); + assert.equal(controller.signal.aborted, false); + assert.equal(sends, 0); +}); + test("Schedule registers one bounded Host job and cleans lifecycle state", async () => { let job: PluginJob | null = null; const context: PluginContext = { From 058c1ba7183c5e82ba9154a88d79ad2512aea8d0 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 11:17:19 +0800 Subject: [PATCH 26/33] fix(schedule): refresh live views and localize reminders --- .trellis/spec/backend/database-guidelines.md | 4 + .trellis/spec/frontend/directory-structure.md | 3 + docs/PLUGIN_API.md | 12 ++ plugins/schedule/README.md | 7 + plugins/schedule/src/reminders.ts | 38 +++- plugins/schedule/web/index.js | 124 ++++++++++- tests/schedule-web.test.ts | 197 +++++++++++++++++- tests/schedule.test.ts | 58 ++++++ 8 files changed, 432 insertions(+), 11 deletions(-) diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index 4f5e44e..724d5c8 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -52,6 +52,10 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl - Store explicit IANA timezone display intent separately from absolute `TIMESTAMPTZ` instants; HTTP inputs must include `Z` or a numeric offset. +- User-facing reminder text formats the stored instant with + `Intl.DateTimeFormat` in the item's IANA timezone. Never display the raw UTC + ISO value beside a non-UTC zone label; invalid legacy zones fall back + explicitly to UTC without rewriting the persisted instant. - Derived UI state such as “awaiting confirmation” is calculated from persisted state + time and is never stored as another status. - Claim a reminder by inserting a unique ledger key before delivery. A ledger diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md index e373c23..caf36b1 100644 --- a/.trellis/spec/frontend/directory-structure.md +++ b/.trellis/spec/frontend/directory-structure.md @@ -37,6 +37,9 @@ web/ ## 关键约束 - 重建书页会销毁输入框——轮询重排前必须 `isEditing()` 检查 +- 带服务端数据或时间派生状态的插件 face 必须实现 `loadLive()`;用稳定快照仅在 + 数据、参考窗口或派生展示状态变化时请求 Host refresh。相同轮询不得重建, + 并发刷新须合并,`unmount()` 后的迟到响应不得再刷新或重新挂载资源。 - 翻页手势(wheel/drag)须跳过 `INTERACTIVE` 选择器内的目标 - 重建时加 `.no-anim` 双 rAF 移除,避免翻页动画闪烁 - Chrome 对 `preserve-3d` 翻转背面页的按钮命中不可靠;左页按钮由 `#leftPageHitProxy` 平面透明层接收并按顺序转发给当前 `.leaf.back` 的真实按钮。新增左页按钮时须保持渲染顺序一致,代理层不得保留重复 `id` 或进入键盘焦点序列。 diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 324f43b..92c7fe5 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -231,6 +231,18 @@ not later than now. Month, week, and day views project the same mutations require `expectedVersion`, and each reminder instant is claimed by a unique ledger dedupe key before delivery. +The ready Web contribution implements both initial and five-second live loads. +It compares a stable snapshot of item data, the reference calendar date, and +derived awaiting state; only a changed snapshot requests a Host book refresh. +Identical polls preserve the current DOM/focus, concurrent refreshes coalesce, +and an unmounted contribution ignores late responses. + +Reminder messages format the stored absolute instant with +`Intl.DateTimeFormat` in the item's IANA timezone, including runtime DST rules. +An invalid legacy timezone falls back visibly to UTC. This is presentation +only: the persisted/HTTP instant remains `TIMESTAMPTZ`/ISO with an explicit +offset. + Claim acquisition forwards the Host caller `AbortSignal` through the lock-and-insert transaction and uses a separate bounded transport timeout. A caller abort or Host timeout/stop cannot produce a late ledger insert after a diff --git a/plugins/schedule/README.md b/plugins/schedule/README.md index 9f2f57d..0104c33 100644 --- a/plugins/schedule/README.md +++ b/plugins/schedule/README.md @@ -44,6 +44,13 @@ settles after that abort, Schedule retains the ledger as `claimed` for diagnosis and performs no late `sent`/`failed` write. Normal channel failure while the caller remains active is still terminalized as `failed`. +Reminder text converts the stored absolute instant into the item's IANA +timezone with `Intl.DateTimeFormat`, so non-UTC and daylight-saving wall times +remain accurate; invalid legacy zones fall back explicitly to UTC. The Web +contribution live-polls the canonical range and refreshes its faces only when +item data, the reference date, or derived awaiting state changes. Unchanged +polls preserve the current DOM, and unmounted contributions ignore late data. + Canonical routes: - `GET|POST /api/plugins/schedule/items` diff --git a/plugins/schedule/src/reminders.ts b/plugins/schedule/src/reminders.ts index c36b446..3920986 100644 --- a/plugins/schedule/src/reminders.ts +++ b/plugins/schedule/src/reminders.ts @@ -85,11 +85,47 @@ function resultOutcome(result: NotificationSendResult): { }; } +function formatWallTime(instant: string, timezone: string): string { + const date = new Date(instant); + if (Number.isNaN(date.getTime())) return String(instant); + const parts = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const value = (type: Intl.DateTimeFormatPartTypes): string | undefined => + parts.find((part) => part.type === type)?.value; + const year = value("year"); + const month = value("month"); + const day = value("day"); + const hour = value("hour"); + const minute = value("minute"); + const second = value("second"); + if (!year || !month || !day || !hour || !minute || !second) { + return String(instant); + } + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} + +function notificationSchedule(item: DueReminder["item"]): string { + try { + return `${formatWallTime(item.scheduledStartAt, item.timezone)} (${item.timezone})`; + } catch (error) { + if (!(error instanceof RangeError)) throw error; + return `${formatWallTime(item.scheduledStartAt, "UTC")} (UTC; invalid timezone ${item.timezone})`; + } +} + function notificationMessage(reminder: DueReminder): string { const item = reminder.item; const description = item.description?.trim(); return [ - `Scheduled for ${item.scheduledStartAt} (${item.timezone}).`, + `Scheduled for ${notificationSchedule(item)}.`, description || null, "Open EchoLog or use el schedule confirm to start explicitly.", ].filter(Boolean).join("\n"); diff --git a/plugins/schedule/web/index.js b/plugins/schedule/web/index.js index 8efa9ed..875b971 100644 --- a/plugins/schedule/web/index.js +++ b/plugins/schedule/web/index.js @@ -137,12 +137,32 @@ function normalizedStatus(item) { function displayStatus(item, now = new Date()) { const status = normalizedStatus(item); - if (status === "scheduled" && Date.parse(item.scheduledStartAt) <= now.getTime()) { + if (status === "scheduled" && Date.parse(item?.scheduledStartAt) <= now.getTime()) { return "awaiting"; } return status; } +function scheduleSnapshot(items, referenceKey, now) { + const entries = items.map((item) => [ + String(item?.id ?? ""), + item?.title ?? null, + item?.description ?? null, + item?.scheduledStartAt ?? null, + item?.scheduledEndAt ?? null, + item?.timezone ?? null, + item?.priority ?? null, + item?.status ?? null, + item?.nextReminderAt ?? null, + item?.confirmedStartAt ?? null, + item?.completedAt ?? null, + item?.cancelledAt ?? null, + item?.version ?? null, + displayStatus(item, now), + ]).sort((left, right) => left[0].localeCompare(right[0])); + return JSON.stringify([referenceKey, entries]); +} + function actionTarget(surface, itemId) { if (!ACTION_SURFACES.has(surface)) throw new Error(`unknown schedule action surface: ${surface}`); return `${surface}:${encodeURIComponent(String(itemId))}`; @@ -335,10 +355,21 @@ function validTimezone(value) { } } -export async function activate({ api, root, now: nowFactory = () => new Date() }) { +export async function activate({ + api, + root, + refresh = async () => {}, + now: nowFactory = () => new Date(), +}) { const stylesheet = mountStylesheet(root); let referenceKey = localDateKey(nowFactory()); let latestItems = []; + let latestCalendar = { referenceKey, ...queryWindow(referenceKey) }; + let observedSnapshot = ""; + let renderedSnapshot = ""; + let fullLoadGeneration = 0; + let refreshPromise = null; + let mounted = true; const setError = ($, id, error) => { const element = $(id) ?? $("scheduleActionError") ?? $("scheduleActionErrorDay"); @@ -350,16 +381,89 @@ export async function activate({ api, root, now: nowFactory = () => new Date() } latestItems = latestItems.map((item) => item.id === updated.id ? updated : item); }; + const currentData = () => ({ + scheduleItems: latestItems, + scheduleCalendar: latestCalendar, + }); + + const isEditing = () => { + const documentRef = root?.ownerDocument ?? globalThis.document; + const element = documentRef?.activeElement; + return Boolean( + element?.closest?.("#pages") && + /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName) + ); + }; + + const fetchSnapshot = async () => { + const observedAt = nowFactory(); + const nextReferenceKey = localDateKey(observedAt); + const window = queryWindow(nextReferenceKey); + const path = `/plugins/schedule/items?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`; + const result = await api(path); + if (!Array.isArray(result)) { + throw new Error("Schedule items response must be an array"); + } + return { + items: result, + calendar: { referenceKey: nextReferenceKey, ...window }, + signature: scheduleSnapshot(result, nextReferenceKey, observedAt), + }; + }; + + const applySnapshot = (snapshot, rendered) => { + referenceKey = snapshot.calendar.referenceKey; + latestItems = snapshot.items; + latestCalendar = snapshot.calendar; + observedSnapshot = snapshot.signature; + if (rendered) renderedSnapshot = snapshot.signature; + }; + + const requestRefresh = async () => { + if (!mounted || isEditing() || observedSnapshot === renderedSnapshot) return; + if (refreshPromise) return refreshPromise; + refreshPromise = (async () => { + while (mounted && !isEditing() && observedSnapshot !== renderedSnapshot) { + const targetSnapshot = observedSnapshot; + const loadGeneration = fullLoadGeneration; + await refresh(); + if (!mounted) return; + // The real Host refresh performs a full load, which installs the + // rendered snapshot. Tests and embedders may provide a lighter + // callback, so acknowledge the requested target only when no full + // load happened while the refresh was in flight. + if (fullLoadGeneration === loadGeneration) { + renderedSnapshot = targetSnapshot; + } + } + })(); + try { + await refreshPromise; + } finally { + refreshPromise = null; + } + }; + return { id: "schedule", async load() { - referenceKey = localDateKey(nowFactory()); - const window = queryWindow(referenceKey); - const path = `/plugins/schedule/items?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`; - const result = await api(path); - if (!Array.isArray(result)) throw new Error("Schedule items response must be an array"); - latestItems = result; - return { scheduleItems: result, scheduleCalendar: { referenceKey, ...window } }; + const snapshot = await fetchSnapshot(); + if (!mounted) return {}; + fullLoadGeneration++; + applySnapshot(snapshot, true); + return currentData(); + }, + async loadLive() { + if (!mounted) return {}; + const snapshot = await fetchSnapshot(); + if (!mounted) return {}; + if (!renderedSnapshot) { + applySnapshot(snapshot, true); + } else { + applySnapshot(snapshot, false); + await requestRefresh(); + } + return mounted ? currentData() : {}; }, faces() { return [...SCHEDULE_FACE_TYPES].map((type) => ({ type })); @@ -462,6 +566,7 @@ export async function activate({ api, root, now: nowFactory = () => new Date() } } }, async unmount() { + mounted = false; stylesheet?.remove?.(); }, }; @@ -475,4 +580,5 @@ export const scheduleWebTest = Object.freeze({ itemDateSpan, queryWindow, rangeForView, + scheduleSnapshot, }); diff --git a/tests/schedule-web.test.ts b/tests/schedule-web.test.ts index 5103cac..e7d99ca 100644 --- a/tests/schedule-web.test.ts +++ b/tests/schedule-web.test.ts @@ -77,6 +77,10 @@ function styleRoot() { }; const documentRef = { head, + activeElement: null as null | { + tagName: string; + closest(selector: string): unknown; + }, createElement(tagName: string) { const link: Record<string, any> = { tagName, @@ -89,7 +93,17 @@ function styleRoot() { return link; }, }; - return { root: { ownerDocument: documentRef }, links }; + return { root: { ownerDocument: documentRef }, links, documentRef }; +} + +function deferred<T>() { + let resolvePromise!: (value: T) => void; + let rejectPromise!: (error: unknown) => void; + const promise = new Promise<T>((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; } function sectionFor(html: string, dateKey: string) { @@ -206,6 +220,187 @@ test("Schedule loads one canonical range and renders month/week/day placement wi assert.equal(contribution.renderFace({ type: "not-schedule" }, context), null); }); +test("Schedule live polling refreshes only changed external, awaiting, and reference snapshots", async () => { + let currentNow = new Date("2026-08-24T12:00:00.000Z"); + let fixtures = [item({ + title: "外部更新前", + scheduledStartAt: "2026-08-24T12:10:00.000Z", + timezone: "UTC", + awaitingConfirmation: false, + })]; + const paths: string[] = []; + let refreshCalls = 0; + const contribution = await activate({ + now: () => currentNow, + refresh: async () => { refreshCalls++; }, + api: async (path: string) => { + paths.push(path); + return fixtures; + }, + }); + + const initial = await contribution.load(); + const initialReference = initial.scheduleCalendar.referenceKey; + await contribution.loadLive(); + assert.equal(refreshCalls, 0, "an unchanged live snapshot must preserve the book DOM"); + + fixtures = [{ ...fixtures[0], title: "CLI 已更新", version: 2 }]; + const external = await contribution.loadLive(); + assert.equal(external.scheduleItems[0].title, "CLI 已更新"); + assert.equal(refreshCalls, 1, "an external API/CLI mutation must refresh Schedule faces"); + await contribution.loadLive(); + assert.equal(refreshCalls, 1, "the same external snapshot must not rebuild twice"); + + currentNow = new Date("2026-08-24T12:10:00.000Z"); + await contribution.loadLive(); + assert.equal(refreshCalls, 2, "crossing scheduledStartAt must refresh derived awaiting state"); + await contribution.loadLive(); + assert.equal(refreshCalls, 2, "stable awaiting state must not rebuild every live tick"); + + currentNow = new Date("2026-08-25T12:00:00.000Z"); + const rolled = await contribution.loadLive(); + assert.notEqual(rolled.scheduleCalendar.referenceKey, initialReference); + assert.equal(refreshCalls, 3, "reference-date rollover must refresh the calendar window"); + assert.equal(paths.length, 7, "every live tick must poll the canonical Schedule range"); +}); + +test("Schedule coalesces overlapping live snapshot refreshes", async () => { + const base = item({ title: "v1", version: 1 }); + const responses = [ + [base], + [{ ...base, title: "v2", version: 2 }], + [{ ...base, title: "v3", version: 3 }], + ]; + let apiCalls = 0; + let refreshCalls = 0; + const firstRefresh = deferred<void>(); + const contribution = await activate({ + now: () => NOW, + api: async () => responses[Math.min(apiCalls++, responses.length - 1)], + refresh: async () => { + refreshCalls++; + if (refreshCalls === 1) await firstRefresh.promise; + }, + }); + await contribution.load(); + + const firstLive = contribution.loadLive(); + while (refreshCalls === 0) await new Promise<void>((resolve) => setImmediate(resolve)); + const secondLive = contribution.loadLive(); + await new Promise<void>((resolve) => setImmediate(resolve)); + assert.equal(refreshCalls, 1, "overlapping polls must share the active refresh"); + + firstRefresh.resolve(); + const [, latest] = await Promise.all([firstLive, secondLive]); + assert.equal(refreshCalls, 2, "one distinct queued snapshot receives one follow-up refresh"); + assert.equal(latest.scheduleItems[0].title, "v3"); + await contribution.loadLive(); + assert.equal(refreshCalls, 2, "the acknowledged queued snapshot must remain stable"); +}); + +test("Schedule does not run a queued refresh after focus begins mid-refresh", async () => { + const base = item({ title: "v1", version: 1 }); + const responses = [ + [base], + [{ ...base, title: "v2", version: 2 }], + [{ ...base, title: "v3", version: 3 }], + ]; + let apiCalls = 0; + let refreshCalls = 0; + const firstRefresh = deferred<void>(); + const { root, documentRef } = styleRoot(); + const contribution = await activate({ + root, + now: () => NOW, + api: async () => responses[Math.min(apiCalls++, responses.length - 1)], + refresh: async () => { + refreshCalls++; + if (refreshCalls === 1) await firstRefresh.promise; + }, + }); + await contribution.load(); + + const firstLive = contribution.loadLive(); + while (refreshCalls === 0) await new Promise<void>((resolve) => setImmediate(resolve)); + documentRef.activeElement = { + tagName: "INPUT", + closest(selector: string) { + return selector === "#pages" ? {} : null; + }, + }; + await contribution.loadLive(); + firstRefresh.resolve(); + await firstLive; + assert.equal( + refreshCalls, + 1, + "a queued snapshot must not rebuild after an input gains focus" + ); + + documentRef.activeElement = null; + await contribution.loadLive(); + assert.equal(refreshCalls, 2, "the queued snapshot must refresh on the first post-blur poll"); +}); + +test("Schedule defers changed live snapshots while a book input has focus", async () => { + const original = item({ title: "before edit", version: 1 }); + let fixtures = [original]; + let refreshCalls = 0; + const { root, documentRef } = styleRoot(); + const contribution = await activate({ + root, + now: () => NOW, + api: async () => fixtures, + refresh: async () => { refreshCalls++; }, + }); + await contribution.load(); + fixtures = [{ ...original, title: "external change", version: 2 }]; + documentRef.activeElement = { + tagName: "INPUT", + closest(selector: string) { + return selector === "#pages" ? {} : null; + }, + }; + + await contribution.loadLive(); + assert.equal(refreshCalls, 0, "live polling must not destroy a focused book input"); + documentRef.activeElement = null; + await contribution.loadLive(); + assert.equal(refreshCalls, 1, "the deferred snapshot must refresh after editing ends"); + await contribution.loadLive(); + assert.equal(refreshCalls, 1); +}); + +test("Schedule ignores late live responses and future polls after unmount", async () => { + const original = item({ title: "mounted", version: 1 }); + const pending = deferred<ReturnType<typeof item>[]>(); + let apiCalls = 0; + let refreshCalls = 0; + const { root, links } = styleRoot(); + const contribution = await activate({ + root, + now: () => NOW, + refresh: async () => { refreshCalls++; }, + api: async () => { + apiCalls++; + return apiCalls === 1 ? [original] : pending.promise; + }, + }); + await contribution.load(); + const live = contribution.loadLive(); + await new Promise<void>((resolve) => setImmediate(resolve)); + await contribution.unmount(); + assert.equal(links.length, 0); + + pending.resolve([{ ...original, title: "late", version: 2 }]); + assert.deepEqual(await live, {}); + assert.equal(refreshCalls, 0, "a late response after unmount must not refresh"); + const callsAfterUnmount = apiCalls; + assert.deepEqual(await contribution.loadLive(), {}); + assert.equal(apiCalls, callsAfterUnmount, "an unmounted contribution must not poll again"); + assert.equal(refreshCalls, 0); +}); + test("Schedule escapes every dynamic render value and never fabricates notification controls", async () => { const malicious = item({ id: 'id"><svg onload=alert(1)>', diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts index 7d80243..3c91396 100644 --- a/tests/schedule.test.ts +++ b/tests/schedule.test.ts @@ -451,6 +451,64 @@ test("reminder polling is at-most-once across repeat polls and store restarts", assert.equal(state.deliveries.size, 2); }); +test("reminder notifications render IANA wall time across offsets, DST, and invalid zones", async () => { + const messageFor = async (scheduledStartAt: string, timezone: string) => { + const state = reminderState(); + const scheduled = item({ + id: `schedule_${timezone}_${scheduledStartAt}`, + scheduledStartAt, + scheduledEndAt: null, + timezone, + nextReminderAt: scheduledStartAt, + }); + state.due = [{ item: scheduled, reminderAt: new Date(scheduledStartAt) }]; + let message = ""; + await pollDueReminders( + new MemoryReminderStore(state), + async (request) => { + message = request.message; + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, + signal + ); + assert.equal(scheduled.scheduledStartAt, scheduledStartAt); + return message; + }; + + const shanghai = await messageFor( + "2026-08-24T01:00:00.000Z", + "Asia/Shanghai" + ); + assert.match(shanghai, /^Scheduled for 2026-08-24 09:00:00 \(Asia\/Shanghai\)\./); + assert.equal(shanghai.includes("01:00:00.000Z (Asia/Shanghai)"), false); + + const beforeDst = await messageFor( + "2026-03-08T06:30:00.000Z", + "America/New_York" + ); + const afterDst = await messageFor( + "2026-03-08T07:30:00.000Z", + "America/New_York" + ); + assert.match(beforeDst, /2026-03-08 01:30:00 \(America\/New_York\)/); + assert.match(afterDst, /2026-03-08 03:30:00 \(America\/New_York\)/); + assert.equal(afterDst.includes("02:30:00"), false); + + const invalid = await messageFor( + "2026-08-24T01:00:00.000Z", + "Mars/Base" + ); + assert.match( + invalid, + /2026-08-24 01:00:00 \(UTC; invalid timezone Mars\/Base\)/ + ); +}); + test("reminder polling terminalizes operational failures but retains caller-aborted claims", async () => { const disabled = reminderState(); await pollDueReminders(new MemoryReminderStore(disabled), async () => ({ From 19f89791c8b9b8155813e4a966bea67234dd55e5 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 12:27:13 +0800 Subject: [PATCH 27/33] fix(inspiration): harden Flow delivery and live clients --- .../spec/backend/plugin-api-guidelines.md | 6 + .trellis/spec/backend/quality-guidelines.md | 5 +- .trellis/spec/frontend/quality-guidelines.md | 9 +- .../08-24-inspiration-clients/check.jsonl | 2 + .../08-24-inspiration-clients/design.md | 29 + .../08-24-inspiration-clients/implement.jsonl | 2 + .../08-24-inspiration-clients/implement.md | 2 + .../2026-08/08-24-inspiration-clients/prd.md | 14 + .../research/chronosprout-reference.md | 32 + .../08-24-inspiration-flow/check.jsonl | 1 + .../2026-08/08-24-inspiration-flow/design.md | 34 +- .../08-24-inspiration-flow/implement.jsonl | 1 + .../08-24-inspiration-flow/implement.md | 10 + .../2026-08/08-24-inspiration-flow/prd.md | 17 +- .../08-24-inspiration-plugin/check.jsonl | 1 + .../08-24-inspiration-plugin/design.md | 53 +- .../08-24-inspiration-plugin/implement.jsonl | 1 + .../08-24-inspiration-plugin/implement.md | 21 + .../2026-08/08-24-inspiration-plugin/prd.md | 40 +- .../research/pr36-review.md | 47 ++ docs/PLUGIN_API.md | 31 +- packages/plugin-sdk/src/index.ts | 6 + plugins/inspiration/README.md | 23 +- plugins/inspiration/package.json | 4 + plugins/inspiration/src/flow-routes.ts | 30 +- plugins/inspiration/src/flow-store.ts | 261 +++++--- plugins/inspiration/src/flow.ts | 83 ++- plugins/inspiration/src/http-validation.ts | 54 ++ plugins/inspiration/src/migrations.ts | 10 + plugins/inspiration/src/notifications.ts | 3 + plugins/inspiration/src/pagination.ts | 47 ++ plugins/inspiration/src/routes.ts | 13 +- plugins/inspiration/src/schema.ts | 2 +- plugins/inspiration/src/types.ts | 7 +- plugins/inspiration/tsup.config.ts | 2 +- plugins/inspiration/web/index.js | 613 ++++++++++++++--- src/cli/index.ts | 32 +- tests/inspiration-capture.test.ts | 35 + tests/inspiration-clients.test.ts | 280 +++++++- tests/inspiration-flow.test.ts | 254 ++++++- tests/inspiration-http.test.ts | 111 ++++ tests/inspiration-notification-host.test.ts | 10 +- tests/inspiration.integration.ts | 627 +++++++++++++----- tests/plugin-notification-host.test.ts | 24 +- 44 files changed, 2455 insertions(+), 434 deletions(-) create mode 100644 .trellis/tasks/archive/2026-08/08-24-inspiration-clients/research/chronosprout-reference.md create mode 100644 .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/pr36-review.md create mode 100644 plugins/inspiration/src/http-validation.ts create mode 100644 plugins/inspiration/src/pagination.ts create mode 100644 tests/inspiration-http.test.ts diff --git a/.trellis/spec/backend/plugin-api-guidelines.md b/.trellis/spec/backend/plugin-api-guidelines.md index e5554fd..e6f8d2e 100644 --- a/.trellis/spec/backend/plugin-api-guidelines.md +++ b/.trellis/spec/backend/plugin-api-guidelines.md @@ -34,6 +34,12 @@ that plugin. typed send function. Core retains global/channel enablement, ntfy server/topic, credentials, delivery timeouts, and transport dependencies. +`PluginNotificationRequest` may carry an optional opaque `dedupeKey`. This is +an additive compatibility field: legacy callers/providers need not set or use +it. A plugin that sends it must namespace it and keep it stable for one logical +delivery, while retaining its own durable ledger because a provider is allowed +to ignore the hint. + Operational delivery outcomes are data, not swallowed exceptions: return both `mac` and `ntfy` with `sent`, `disabled`, or `failed`. Failed results contain a bounded, non-sensitive error and never include endpoint URLs, topics, response diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index 1117be1..b211e90 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -45,8 +45,9 @@ ### 持久化插件投递任务 -- 时间 bucket 的唯一 dedupe key 只能防当前 bucket 重复,不能单独承担崩溃恢复:daemon 可能在写入 `reserved` 后、完成外部投递前退出,并在下一 bucket 才重启。创建新投递前必须先认领最旧的 stale `reserved` 行,保留原 dedupe key,并记录 attempt 次数。 -- 恢复认领要在事务中使用短租约、版本/状态前置条件和 `.returning()`;租约内的重复轮询只观察既有投递,不再次调用外部服务。外部服务仍须消费同一个 dedupe key,兜住超出租约的非协作超时。 +- 时间 bucket 的唯一 dedupe key 只能防当前 bucket 重复,不能单独承担崩溃恢复:daemon 可能在外部投递已经成功、数据库 finalize 前退出。没有强一致外部幂等保证时,调用前必须先把 ledger 原子迁移到显式 in-flight 状态;stale `reserved`/in-flight 行只能终结为 unknown/failed 并停止本轮,绝不能从同一行再次真实发送。 +- 明确失败可以按产品规则在新 bucket、新 delivery 上重试;原 delivery 保留失败诊断。若外部服务支持可选 dedupe hint,使用稳定、带插件命名空间的 delivery key,但仍不能假设 provider 一定执行幂等,插件 ledger 必须独立保证同一行 at-most-once。 +- in-flight/终结迁移要使用事务、版本/状态前置条件和 `.returning()`;重复轮询只能观察或终结既有行。分页 ledger 时使用与排序完全一致的复合 cursor,避免相同时间戳漏行。 - `AbortSignal` 检查不能只放在事务入口。每个可能等待行锁/ advisory lock 的语句返回后、以及任何持久状态变更前后都要再次检查,使 Host 超时释放 non-reentry 后,迟到的事务能回滚而不是继续写入。 ### 结构化诊断端点 diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md index 05a1411..4661972 100644 --- a/.trellis/spec/frontend/quality-guidelines.md +++ b/.trellis/spec/frontend/quality-guidelines.md @@ -32,7 +32,11 @@ Questions to answer: <!-- Patterns that must always be used --> -(To be filled by the team) +- Plugin contributions using `loadLive()` must explicitly bridge changed live + data to rendered plugin faces; Core live DOM patching does not imply plugin + face rendering. +- Live refresh must be change-sensitive, preserve active input state, and make + late asynchronous work inert after contribution unmount. --- @@ -40,7 +44,8 @@ Questions to answer: <!-- What level of testing is expected --> -(To be filled by the team) +- Live contribution tests cover changed snapshots, unchanged polling, editing + deferral, and unmount during an in-flight request. --- diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl index e3710be..f88a63d 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl @@ -1,3 +1,5 @@ {"file":".trellis/spec/backend/cli-agent-contract.md","reason":"Verify CLI agent-facing compatibility"} {"file":".trellis/spec/frontend/directory-structure.md","reason":"Verify ready-only native Web contribution and escaping"} {"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Frontend quality gate"} +{"file": ".trellis/tasks/08-24-inspiration-plugin/research/pr36-review.md", "reason": "Check composite cursor and shared ISO validation"} +{"file": ".trellis/tasks/08-24-inspiration-clients/research/chronosprout-reference.md", "reason": "Verify adaptation preserves EchoLog boundaries and avoids copied product semantics"} diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md index e409850..3894c1d 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md @@ -20,3 +20,32 @@ resolve active records, or perform schedule conversion. Web follows the Shell contribution contract (`faces/load/loadLive/renderFace/handleAction/unmount`). The plugin `index.ts` integration owned by the parent will register the report section using a backend summary method exposed by the agreed service contract. + +## PR #36 shared validation/pagination + +`@echolog/plugin-inspiration/http-validation` is a pure package subpath with no +database or service imports. Both plugin HTTP routes and the root CLI import its +offset-aware ISO validator. `pagination.ts` owns opaque delivery cursor +encoding/decoding; clients pass cursors through and never reconstruct them. + +## Frontend reference adaptation + +The user-provided Chronosprout Web is a visual/interaction reference, not a +domain or data-contract dependency. EchoLog adapts its focused card hierarchy, +compact metadata/tag treatment, clear action bar, history rail/list, responsive +breakpoints, focus visibility, and reduced-motion treatment inside the existing +Inspiration contribution. It does not copy confidence/Agent/evidence/revival +fields, offline export behavior, or archive/extract/revive semantics. + +## Live contribution invalidation + +The Shell merges `loadLive()` data but its Core `liveSignature()` and +`patchLiveDom()` deliberately cover Core faces only. The Inspiration +contribution uses the `refresh` and `root` values already supplied at +activation. It signatures the rendered Inbox and Flow snapshots separately, +requests a Host refresh only on a real change, and retains the old rendered +state while an input under `#pages` is active. The next quiet poll retries the +pending invalidation. Unmount advances a lifecycle generation so in-flight and +future live loads cannot mutate state or request refresh. Polls that overlap a +slow Host rebuild wait on one contribution-owned refresh promise before taking +their next snapshot, preventing duplicate whole-book rebuilds. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl index 563f346..2dd7dca 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl @@ -2,3 +2,5 @@ {"file":".trellis/spec/frontend/directory-structure.md","reason":"Native JS contribution, escaping, and event delegation conventions"} {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Keep DTOs and backend policy consistent"} {"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Existing CLI/Web contribution mechanics"} +{"file": ".trellis/tasks/08-24-inspiration-plugin/research/pr36-review.md", "reason": "HTTP pagination/timezone findings"} +{"file": ".trellis/tasks/08-24-inspiration-clients/research/chronosprout-reference.md", "reason": "User-provided Inspiration frontend visual and interaction reference"} diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md index 5a4fbfc..61dff47 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md @@ -6,6 +6,8 @@ - [x] Add client/Web tests for paths, JSON/error behavior, actions, and absence of schedule semantics. - [x] Add a report renderer/helper or contract consumed by parent integration. +- [x] Add snapshot-sensitive live invalidation with focus preservation and + unmount/overlap guards, plus changed/unchanged/unmount tests. - [x] Run focused tests and typecheck; report changed files only. Validation: `pnpm exec tsx --test tests/inspiration-clients.test.ts`, diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md index 561921c..d384c08 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md @@ -28,3 +28,17 @@ and a concise daily-report section without duplicating backend policy. through CLI/API. - [x] Dynamic Web text is escaped and no schedule UI/action exists. - [x] Daily report contribution is covered by tests. + +## PR #36 HTTP/client repair + +- [x] Delivery history uses an opaque composite cursor returned by the API and + accepted by CLI/Web without timestamp-only pagination. +- [x] Capture date filters reject timezone-less input in both HTTP and CLI and + accept explicit DST-era offsets. +- [x] Flow UI presents outcomes only for successful sent manual deliveries; + failed deliveries are visibly marked as not shown and cannot submit actions. +- [x] Inbox and Flow adapt the user-provided Chronosprout card/rail hierarchy + with scoped responsive styles while preserving EchoLog fields, actions, + escaping, ready-only loading, and canonical HTTP behavior. +- [x] Live Inbox/Flow snapshot changes request one Host refresh, unchanged polls + request none, editing defers refresh, and unmount cancels late invalidation. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/research/chronosprout-reference.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/research/chronosprout-reference.md new file mode 100644 index 0000000..f9db9e7 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/research/chronosprout-reference.md @@ -0,0 +1,32 @@ +# Chronosprout frontend reference + +User-provided local reference: +`/Users/sc/0code/0toy/0HKT/eoove-labs-chronosprout`. + +Relevant source files reviewed on 2026-08-26: + +- `web/src/main.js` +- `web/src/style.css` +- `web/index.html` +- `web/README.md` + +Patterns to adapt within EchoLog's existing Web contribution contract: + +- A focused idea-card stage with clear project/status metadata and tag chips. +- Strong visual hierarchy between the current inspiration, outcome controls, + settings, and the delivery-history rail/list. +- Compact state markers, empty/failure states, responsive layout, visible focus, + and reduced-motion behavior. +- Scoped native JavaScript/CSS with escaped dynamic values and no runtime + dependency. + +Patterns intentionally not copied: + +- AI confidence, agent trace, evidence, repository, or revival fields. +- Suiya archive/extract/revive execution semantics. +- Offline JSON export/data bridge, global page shell, or keyboard mappings that + would conflict with EchoLog. + +EchoLog invariants remain authoritative: ready-only loading, canonical HTTP +APIs, failed deliveries are non-actionable, no Schedule integration, and no +screenshots/prompts/replies stored. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl index 65e3eb8..2688937 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl @@ -2,3 +2,4 @@ {"file":".trellis/spec/backend/quality-guidelines.md","reason":"Review job timeout/non-reentry and failure recovery"} {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review Flow state/lifecycle separation"} {"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Review notification permission, channel outcomes, and privacy"} +{"file": ".trellis/tasks/08-24-inspiration-plugin/research/pr36-review.md", "reason": "Check at-most-once and settings race"} diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md index 74e1339..811035a 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md @@ -16,14 +16,16 @@ The only host dependency is the SDK-exported function: ```ts type PluginNotificationSend = ( - request: { title: string; message: string }, + request: { title: string; message: string; dedupeKey?: string }, signal?: AbortSignal ) => Promise<PluginNotificationResult>; ``` It is resolved lazily with `context.service("notifications.send")`; the manifest -declares `notifications:send`. Inspiration passes no dedupe key or entity IDs to -Core. A delivery-owned JSONB projection stores bounded `mac`/`ntfy` channel +declares `notifications:send`. Inspiration passes a stable +`inspiration:${delivery.dedupeKey}` hint but no other entity metadata. Providers +may ignore this additive field, so the delivery ledger still enforces the state +machine. A delivery-owned JSONB projection stores bounded `mac`/`ntfy` channel results. Overall success requires at least one `sent` channel. Tests use the real PluginHost permission gate and function service in addition to unit mocks. @@ -41,10 +43,22 @@ bypass only those two gates, never cooldown, snooze, filters, or daily cap. ## Restart/failure semantics -The ledger is source of truth. A reserved row survives daemon restart. A send -failure is finalized as `failed`; a later dedupe bucket can retry the same -inspiration if still eligible. Before selecting for a new scheduled bucket, the -store claims the oldest stale `reserved` delivery with a short lease and -increments its durable attempt count. This recovers work even when restart -crosses an interval boundary without letting an immediate repeated poll send -twice. No prompt/reply/screenshot body is stored. +The ledger is source of truth. Before an external call the row transitions to +`dispatching`. A stale `reserved` or `dispatching` row becomes a terminal +unknown failure and is never sent again from that row. A clearly failed send +may be retried only through a later policy-selected bucket and a new delivery. +No prompt/reply/screenshot body is stored. + +### PR #36 correction + +The earlier stale-reservation retry is superseded by at-most-once semantics. +Before the external call, the Store atomically claims the row into an in-flight +state. Any stale reserved/in-flight row is terminally failed with an unknown +outcome and `shouldNotify=false`; it is never reclaimed for another external +send. Explicit notification failure remains a normal failed row, and a future +bucket can retry only by creating a new delivery after normal policy selection. + +Scheduled dedupe keys are generated inside `reserveNext` after locking settings, +using that row's version and interval. This removes transaction-outside races. +Failed rows are diagnostic and never accept outcomes, regardless of source. +Only sent deliveries represent a successful user-visible surfacing. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl index 68f4eac..125893f 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl @@ -3,3 +3,4 @@ {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Selector, delivery, notification, and API boundary design"} {"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Host job/service and plugin persistence research"} {"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Implement official function-valued notification service"} +{"file": ".trellis/tasks/08-24-inspiration-plugin/research/pr36-review.md", "reason": "Flow state/race findings"} diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md index 7d58eb2..6cf36d2 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md @@ -16,5 +16,15 @@ - [x] Update manifest, units/mocks, Web/CLI DTO fixtures, PostgreSQL integration, and real PluginHost contract tests. +## PR #36 reliability repair + +- [x] Add pre-send at-most-once state and terminal stale recovery. +- [x] Move scheduled key generation into the locked settings transaction. +- [x] Make all failed deliveries terminal/non-actionable and preserve explicit + diagnostics plus distinct-delivery retry behavior. +- [x] Pass one stable namespaced notification key per delivery and test duplicate + and retry identities. +- [x] Replace delivery time-only pagination with composite cursor contract. + Validation: `pnpm --filter @echolog/plugin-inspiration typecheck` and `pnpm exec tsx --test tests/inspiration-flow.test.ts`. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md index 50653ae..6b7a98d 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md @@ -41,9 +41,22 @@ inspiration lifecycle. - [x] Manifest declares `notifications:send`; missing permission is denied by a real PluginHost with `PLUGIN_DEPENDENCY_MISSING` before service invocation. -- [x] The SDK `PluginNotificationSend` function receives exactly `{title, - message}` and never dedupe/entity metadata. +- [x] The SDK `PluginNotificationSend` function receives `{title,message}` plus + an optional stable `inspiration:`-namespaced delivery dedupe key; old callers + and providers remain compatible. - [x] At least one `sent` channel finalizes delivery as sent; all-disabled or no sent channel finalizes it as failed while retaining safe per-channel status. - [x] Lazy service absence/failure remains ledgered and does not prevent Capture or Core startup. + +## PR #36 state and race acceptance + +- [x] A delivery transitions to a pre-send state before calling Core; stale + pre-send/in-flight rows are failed as unknown without another send. +- [x] Failed deliveries from either source are terminal diagnostics and reject + outcomes; only sent deliveries are actionable. +- [x] Duplicate calls for one delivery use one notification key without another + send, while a distinct retry delivery uses a different namespaced key. +- [x] Scheduled key generation and selection share one locked FlowSettings + version/interval snapshot. +- [x] Delivery page boundaries include surfaced timestamp and id. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl index b9cb308..e6633e3 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl @@ -3,3 +3,4 @@ {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Full-stack contract consistency review"} {"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Compare implementation with established plugin patterns"} {"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Verify exact named-service contract and permission gate"} +{"file": ".trellis/tasks/08-24-inspiration-plugin/research/pr36-review.md", "reason": "Verify every PR #36 finding is closed"} diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md index ba903d6..4fb9524 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md @@ -64,9 +64,50 @@ branch as `8484b48`. It exports `PluginNotificationSend` and service, and gates it with manifest permission `notifications:send`. Inspiration must not wrap or redefine that service. It lazily resolves the SDK -function and calls it with only `{title, message}`. Delivery `dedupeKey`, -`inspirationId`, and `deliveryId` never cross the Core service boundary. A new -append-only plugin migration stores the exact bounded per-channel result -projection in the delivery ledger. One or more `sent` channels means delivered; -all-disabled/all-failed/mixed-disabled-failed means not delivered. Thrown service -errors remain generic in the ledger so notification content cannot be reflected. +function and calls it with notification text plus the additive optional +`dedupeKey` `inspiration:${delivery.dedupeKey}`. The key is stable for a ledger +row and namespaced across plugins; the private ledger remains authoritative +because an existing provider may ignore the hint. Inspiration and raw delivery +IDs do not otherwise cross the service boundary. A new append-only plugin +migration stores the exact bounded per-channel result projection in the +delivery ledger. One or more `sent` channels means delivered; all-disabled, +all-failed, or mixed-disabled-failed means not delivered. Thrown service errors +remain generic in the ledger so notification content cannot be reflected. + +## PR #36 reliability contracts + +Delivery dispatch has an explicit pre-send transition. Once a row has crossed +that boundary, timeout/crash recovery may observe or terminally mark an unknown +outcome but MUST NOT call `notifications.send` again for that row. This favors +at-most-once delivery because the official Core request has no dedupe key. A new +later bucket may select the inspiration again only through normal policy. + +Scheduled reservation owns its dedupe key: it locks the singleton settings row, +then derives a key containing the locked version and interval before selection. +No caller computes a scheduled key from a pre-transaction settings read. + +Delivery pagination order is `(surfaced_at DESC, id DESC)`. The opaque cursor +encodes both fields; the next-page predicate is `surfaced_at < t OR +(surfaced_at = t AND id < id)`. A pure package subpath validator accepts only +offset-aware ISO strings (`Z` or `±HH:mm`) and is shared by HTTP and CLI without +importing persistence/business modules. + +The Web Host passes each ready contribution `refresh` and `root`, while its +five-second `loadLive` merge does not render plugin faces. Inspiration therefore +keeps separate presented signatures for the Inbox list and Flow ledger. A +changed signature requests the existing Host refresh once; an unchanged poll +does nothing. Refresh is deferred while a page input is active so typed values +and optimistic versions remain intact. Lifecycle/request generations discard +late responses after unmount and older overlapping live requests. A single +in-flight refresh gate serializes later polls behind the current Host rebuild, +so the same changed snapshot cannot launch concurrent `refreshBook()` calls. + +## Parallel file ownership + +- Store/Flow agent: `flow-store.ts`, `flow.ts`, `types.ts`, `schema.ts`, + `migrations.ts`, `tests/inspiration-flow.test.ts`. +- HTTP/pagination agent: `http-validation.ts`, `pagination.ts`, capture/Flow + routes, package export/build metadata, root Inspiration CLI block, Web module, + and client tests. +- PostgreSQL agent: `tests/inspiration.integration.ts` only. +- Main: Trellis/docs, cross-agent interface resolution, full validation/commit. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl index 83d4364..d3a46d5 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl @@ -3,3 +3,4 @@ {"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Plugin spans persistence, HTTP, CLI, Web, jobs, and reports"} {"file":".trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md","reason":"Repository-specific bundled-plugin research"} {"file": ".trellis/spec/backend/plugin-api-guidelines.md", "reason": "Official notifications.send SDK, permission, and Host contract"} +{"file": ".trellis/tasks/08-24-inspiration-plugin/research/pr36-review.md", "reason": "PR #36 P1/P2 source findings and required resolutions"} diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md index a637a39..c7df884 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md @@ -23,5 +23,26 @@ - [x] Replace mocks and add real PluginHost contract integration tests. - [x] Run independent check, full validation, append repair commit, and re-archive. +## PR #36 review iteration + +- [x] Implement at-most-once dispatch and settings-snapshot dedupe in Store/Flow. +- [x] Implement composite delivery cursor and shared offset-aware ISO validation + across HTTP/CLI/Web. +- [x] Add the required real PostgreSQL race/crash/pagination regressions. +- [x] Integrate three parallel implementations and run focused/full checks. +- [x] Dispatch an independent SOL High reviewer, fix findings, append commit, + update PR tracking, and re-archive. + +## PR #36 Web live-refresh iteration + +- [x] Compare Inspiration `loadLive` behavior with the actual Web Host polling, + contribution refresh, editing, and unmount contracts. +- [x] Add change-sensitive Host invalidation with editing deferral and stale + request/unmount guards without changing global Host polling. +- [x] Add automated changed/unchanged/deferred/unmounted live snapshot tests. +- [x] Run full validation and independent P0-P2 review; close the reviewer's + overlapping-refresh P2 with a coalescing regression. +- [ ] Append one commit with all accumulated Inspiration repairs. + Rollback points: before root registry integration; before docs/Issue update; before commit. Never merge another branch. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md index a94ed55..e0bf79f 100644 --- a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md @@ -64,8 +64,9 @@ implementation, validation, documentation synchronization, and commit. auditable cherry-pick and its SDK/Host tests remain intact. - [x] Inspiration declares `notifications:send` and consumes the SDK-exported function contract instead of a local object-shaped service. -- [x] Flow sends only `{ title, message }`; dedupe and entity identifiers remain - private delivery-ledger fields. +- [x] Flow sends notification text plus an optional stable, namespaced delivery + `dedupeKey`; providers remain backward compatible and the private ledger stays + authoritative. - [x] Per-channel `sent|disabled|failed` results are persisted in a bounded, non-sensitive ledger projection; overall delivery succeeds only when at least one channel reports `sent`. @@ -73,3 +74,38 @@ implementation, validation, documentation synchronization, and commit. invocation, channel combinations, and absence of a `.send()` assumption. - [x] Full test, typecheck, build, diff check, independent review, repair commit, and re-archive are complete without rewriting `3ab8946`. + +## PR #36 Reliability Repair + +- [x] Notification dispatch is at-most-once: after an external call begins, an + interrupted/stale delivery is terminally diagnosed as unknown/failed and is + never re-sent from that ledger row. +- [x] A clearly failed delivery may become eligible in a later dedupe bucket + according to normal Flow cooldown/filter/daily-limit rules. +- [x] Scheduled dedupe keys are derived from the same locked settings/version + snapshot used by selection, including concurrent interval updates. +- [x] Delivery pagination uses an opaque `{surfacedAt,id}` composite cursor and + cannot skip equal-timestamp rows. +- [x] All user-provided date-times require `Z` or `±HH:mm` through one pure + validator shared by plugin HTTP and CLI validation. +- [x] Failed deliveries are terminal diagnostics for both sources; only sent + deliveries accept user outcomes, and retry creates a distinct later delivery. +- [x] `notifications.send` receives the same namespaced key for one delivery and + a different key for a distinct retry delivery without weakening lazy service + resolution, permission, timeout, or degraded-plugin isolation. +- [x] Real PostgreSQL regressions cover crash-after-send-before-finalize, + settings interval race, composite pagination, and are callable by + `test:integration`. +- [x] Three implementation agents plus an independent check complete with no + remaining P0/P1/P2; full verification and an additive fix commit land without + rewriting prior history. + +## PR #36 Web live-refresh repair + +- [x] Inspiration live polling invalidates the Host only when the Inbox or Flow + snapshot actually changes; unchanged five-second polls never rebuild faces. +- [x] Live invalidation defers while a Host page input is active, retries after + editing ends, and reconciles the current Flow candidate from the refreshed + delivery snapshot. +- [x] In-flight or later `loadLive` work becomes inert after contribution + unmount, with automated change/no-change/unmount coverage. diff --git a/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/pr36-review.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/pr36-review.md new file mode 100644 index 0000000..5236c10 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/pr36-review.md @@ -0,0 +1,47 @@ +# PR #36 Code Review Findings + +Source: GitHub PR #36 review at commit `b751338a0a`, plus the delegated +reliability requirements for this worktree. + +- P1: notification can be externally delivered before DB finalization; stale + resend is unsafe because official `PluginNotificationSend` has no dedupe key. + Resolution: at-most-once after pre-send claim; stale rows terminally fail + unknown and are never re-sent. +- P2: scheduled key used an interval read outside the settings-lock transaction. +- P2: delivery timestamp-only pagination skips equal-timestamp rows. +- P2: HTTP accepted timezone-less date filters; HTTP and CLI must share strict + offset-aware validation. +- P2: real PostgreSQL regressions are required here even though CI workflow + wiring belongs to the integration branch. +- Additional inline P2: manually surfaced notification failures were displayed + with outcome buttons but backend rejected every outcome. + +Schedule remains completely outside this plugin and repair. + +## Latest review at PR head `d384adb` (2026-08-26) + +- Failed manual deliveries must not masquerade as actionable user-visible + candidates. Resolution: every failed delivery is terminal and diagnostic; + only sent deliveries can accept outcomes. Retrying creates a new bucket and + delivery. +- `notifications.send` needs a stable, collision-resistant delivery key. + Resolution: add the optional SDK request field compatibly and pass + `inspiration:${delivery.dedupeKey}`. The plugin ledger remains authoritative + because compatible providers may ignore this hint. +- Reconfirm the `(surfaced_at,id)` cursor against equal-timestamp and boundary + regressions. No Schedule dependency is introduced. + +## Latest Web live-refresh review (2026-08-26) + +- Inspiration `loadLive()` updated closure snapshots, but the Shell's + `liveSignature()` and `patchLiveDom()` cover only Core faces, leaving mounted + Inbox/Flow DOM stale after scheduled Flow or another client writes. +- Resolution: compare presented Inbox/Flow snapshot signatures and use the + activation-time Host `refresh` callback only after a real change. Defer while + a page input is active, keep the old optimistic client state until refresh, + and invalidate in-flight work on unmount. +- Independent review found that advancing the presented signature only after a + slow refresh allowed a second overlapping poll to start another rebuild for + the same snapshot. The final implementation gates new polls behind one + `refreshInFlight` promise and includes an overlap regression; re-review found + no remaining P0/P1/P2. diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index 2f1e0e7..a15bda9 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -128,17 +128,21 @@ const result = await sendNotification( { title: "Reminder", message: "Stand-up starts in five minutes", + dedupeKey: "my-plugin:delivery-01", }, signal ); ``` -The request contains only `title` and `message`; the optional second argument -is the caller's `AbortSignal`. Caller cancellation rejects the service call with -an error named `AbortError`; it MUST NOT resolve a channel result, because a -Host timeout or shutdown cannot know whether an in-flight transport accepted -the notification. Plugins MUST leave durable delivery state retryable when they -receive this cancellation. +The request requires `title` and `message` and may include an opaque, +caller-namespaced `dedupeKey`. Existing providers may ignore this additive +field, so plugins must still make their own ledger transitions safe; when it is +provided it must remain stable for one logical delivery. The optional second +argument is the caller's `AbortSignal`. Caller cancellation rejects the service +call with an error named `AbortError`; it MUST NOT resolve a channel result, +because a Host timeout or shutdown cannot know whether an in-flight transport +accepted the notification. Plugins MUST leave durable delivery state retryable +when they receive this cancellation. Core-owned transport timeouts and operational channel errors are different: they resolve the normal result with that channel marked `failed`. Without caller @@ -264,13 +268,14 @@ table relationship. Flow resolves the named service `notifications.send` lazily through `PluginContext.service()`, using the SDK-exported `PluginNotificationSend` -function. It sends only `{title, message}`. Inspiration-owned dedupe keys, -inspiration IDs, and delivery IDs never cross the Core service boundary. The -plugin persists the bounded `mac`/`ntfy` result projection in its private -delivery ledger and treats the delivery as sent only when at least one channel -reports `sent`. The notification service is Host-owned; the plugin MUST NOT -import or copy the Core notifier. Missing/failing delivery is recorded while -capture remains available. +function. It sends `{title,message}` plus a stable, namespaced delivery +`dedupeKey`; inspiration and delivery IDs otherwise remain private to the +plugin ledger. The ledger remains authoritative because providers may ignore +the additive key. The plugin persists the bounded `mac`/`ntfy` result +projection in its private delivery ledger and treats the delivery as sent only +when at least one channel reports `sent`. The notification service is +Host-owned; the plugin MUST NOT import or copy the Core notifier. +Missing/failing delivery is recorded while capture remains available. ## Bundled Schedule plugin diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index af81fd4..ea88af6 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -113,6 +113,12 @@ export type PluginNotificationChannel = "mac" | "ntfy"; export interface PluginNotificationRequest { title: string; message: string; + /** + * Optional caller-scoped idempotency key. Existing notification providers + * may ignore it; callers that set it must keep it stable for one logical + * delivery and namespace it to avoid collisions with other plugins. + */ + dedupeKey?: string; } export type PluginNotificationChannelResult = diff --git a/plugins/inspiration/README.md b/plugins/inspiration/README.md index b2a107e..f10d19c 100644 --- a/plugins/inspiration/README.md +++ b/plugins/inspiration/README.md @@ -33,11 +33,16 @@ Manual `next` and the scheduled job use the same deterministic selector: never-surfaced inspirations first, then oldest `lastSurfacedAt`, creation time, and id. Settings control lifecycle/tag/project filters, cooldown, quiet hours, daily cap, and default snooze. The delivery ledger and unique dedupe keys make -repeated polling and daemon restarts observable and idempotent. Each delivery -tracks its notification attempt count. A short reservation lease prevents an -immediate duplicate poll from sending twice, while the scheduler claims the -oldest stale `reserved` delivery before selecting a new candidate, including -after restart into a different interval bucket. +repeated polling and daemon restarts observable. Before calling the external +notification service, a delivery crosses a durable `dispatching` boundary. +Stale reserved/dispatching rows are terminally diagnosed as an unknown failure +and are never sent again from the same ledger row; an explicit failure may be +retried only through a distinct later bucket and delivery. Failed deliveries +are diagnostic, not actionable; user outcomes apply only to sent deliveries. + +Delivery history is ordered by `(surfacedAt DESC, id DESC)` and uses an opaque +composite cursor. All date-time filters and cursor timestamps must include `Z` +or an explicit `\u00b1HH:mm` offset. The first version uses no AI or embeddings and stores no screenshots, prompt, reply, reasoning, or terminal content. @@ -48,7 +53,7 @@ Flow resolves exactly one host service lazily: ```ts type PluginNotificationSend = ( - request: { title: string; message: string }, + request: { title: string; message: string; dedupeKey?: string }, signal?: AbortSignal ) => Promise<{ channels: Record<"mac" | "ntfy", @@ -60,8 +65,10 @@ type PluginNotificationSend = ( ``` The service name is `notifications.send` and the manifest declares the matching -`notifications:send` permission. The request contains only notification text; -dedupe keys and inspiration/delivery ids remain private to the plugin ledger. +`notifications:send` permission. The request contains notification text plus a +stable `inspiration:`-namespaced delivery dedupe key. The private ledger remains +authoritative because a compatible provider may ignore this additive hint; +inspiration and raw delivery ids are not otherwise exposed. At least one `sent` channel marks a delivery sent. Otherwise it is failed, with the bounded per-channel result retained for diagnostics. Service resolution is lazy, so a missing or failed notification capability is recorded as a failed diff --git a/plugins/inspiration/package.json b/plugins/inspiration/package.json index 281363c..add53b6 100644 --- a/plugins/inspiration/package.json +++ b/plugins/inspiration/package.json @@ -9,6 +9,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./http-validation": { + "types": "./dist/http-validation.d.ts", + "import": "./dist/http-validation.js" } }, "files": [ diff --git a/plugins/inspiration/src/flow-routes.ts b/plugins/inspiration/src/flow-routes.ts index dd7ec77..78c5697 100644 --- a/plugins/inspiration/src/flow-routes.ts +++ b/plugins/inspiration/src/flow-routes.ts @@ -5,6 +5,11 @@ import type { } from "@echolog/plugin-sdk"; import { FlowStoreError } from "./flow-store.js"; import type { FlowService } from "./flow.js"; +import { + decodeDeliveryCursor, + encodeDeliveryCursor, +} from "./pagination.js"; +import type { DeliveryCursor } from "./pagination.js"; import type { FlowOutcome, FlowOutcomeInput, @@ -339,23 +344,30 @@ export function createFlowRoutes(service: () => FlowService): PluginRoute[] { return response(400, { error: "query must be an object" }); } const value = query as Record<string, unknown>; - if (!hasExactKeys(value, ["limit", "before"])) { + if (!hasExactKeys(value, ["limit", "cursor"])) { return response(400, { error: "deliveries query contains unknown fields" }); } const rawLimit = value.limit === undefined ? 50 : Number(value.limit); const limit = integer(rawLimit, "limit", 1, 100); if (!limit.ok) return response(400, { error: limit.error }); - let before: Date | undefined; - if (value.before !== undefined) { - if (typeof value.before !== "string") { - return response(400, { error: "before must be an ISO 8601 timestamp" }); + let cursor: DeliveryCursor | undefined; + if (value.cursor !== undefined) { + if (typeof value.cursor !== "string") { + return response(400, { error: "cursor must be specified once" }); } - before = new Date(value.before); - if (!value.before.includes("T") || Number.isNaN(before.getTime())) { - return response(400, { error: "before must be an ISO 8601 timestamp" }); + const decoded = decodeDeliveryCursor(value.cursor); + if (!decoded) { + return response(400, { error: "cursor is invalid" }); } + cursor = decoded; } - return { deliveries: await service().listDeliveries(limit.value, before) }; + const page = await service().listDeliveries(limit.value, cursor); + return { + deliveries: page.deliveries, + nextCursor: page.nextCursor + ? encodeDeliveryCursor(page.nextCursor) + : null, + }; }, }, { diff --git a/plugins/inspiration/src/flow-store.ts b/plugins/inspiration/src/flow-store.ts index 463c2b1..0c2922e 100644 --- a/plugins/inspiration/src/flow-store.ts +++ b/plugins/inspiration/src/flow-store.ts @@ -1,11 +1,8 @@ import { nanoid } from "nanoid"; import postgres from "postgres"; import type { PluginNotificationResult } from "@echolog/plugin-sdk"; -import { - isQuietMinute, - minuteOfLocalDay, - selectFlowCandidate, -} from "./selector.js"; +import { selectFlowCandidate } from "./selector.js"; +import type { DeliveryCursor } from "./pagination.js"; import type { DailyInspirationSummary, FlowCandidate, @@ -96,6 +93,11 @@ export interface FlowOutcomeResult { inspiration: Inspiration; } +export interface FlowDeliveryPage { + deliveries: FlowDelivery[]; + nextCursor: DeliveryCursor | null; +} + export type FlowNotificationFinalization = | { delivered: true; @@ -173,6 +175,25 @@ function mapDelivery(row: DeliveryRow): FlowDelivery { } export const FLOW_RESERVATION_LEASE_MS = 30_000; +export const FLOW_UNKNOWN_DISPATCH_ERROR = + "notification outcome unknown after interrupted dispatch"; + +export function scheduledFlowDedupeKey( + now: Date, + settingsVersion: number, + intervalMinutes: number +): string { + const intervalMs = intervalMinutes * 60_000; + return `scheduled:${settingsVersion}:${intervalMinutes}:${Math.floor( + now.getTime() / intervalMs + )}`; +} + +export function canApplyFlowOutcome( + delivery: Pick<FlowDelivery, "status"> +): boolean { + return delivery.status === "sent"; +} function startOfLocalDay(value: Date): Date { return new Date( @@ -236,21 +257,44 @@ export class FlowStore { async reserveNext( source: FlowSource, - dedupeKey: string, + dedupeKey: string | undefined, now = new Date(), signal?: AbortSignal ): Promise<FlowReserveResult> { signal?.throwIfAborted(); return this.sql.begin(async (transaction) => { + // Scheduled identity is part of the settings snapshot. Locking first + // ensures a concurrent interval update either wholly precedes or wholly + // follows both key generation and candidate selection. + const settingsRows = await transaction<SettingsRow[]>` + SELECT * FROM inspiration_flow_settings WHERE id = 'default' FOR UPDATE + `; + signal?.throwIfAborted(); + const settingsRow = settingsRows[0]; + if (!settingsRow) throw new Error("inspiration Flow settings are unavailable"); + const settings = mapSettings(settingsRow); + const resolvedDedupeKey = source === "scheduled" + ? scheduledFlowDedupeKey( + now, + settings.version, + settings.intervalMinutes + ) + : dedupeKey; + if (!resolvedDedupeKey) { + throw new Error("manual Flow reservation requires a dedupe key"); + } + // The advisory lock turns a concurrent unique-key race into a normal // idempotent lookup, without leaving the losing transaction aborted. await transaction` - SELECT pg_advisory_xact_lock(hashtextextended(${dedupeKey}, 0)) + SELECT pg_advisory_xact_lock(hashtextextended(${resolvedDedupeKey}, 0)) `; signal?.throwIfAborted(); const duplicateRows = await transaction<DeliveryRow[]>` - SELECT * FROM inspiration_flow_deliveries WHERE dedupe_key = ${dedupeKey} + SELECT * FROM inspiration_flow_deliveries + WHERE dedupe_key = ${resolvedDedupeKey} + FOR UPDATE `; signal?.throwIfAborted(); @@ -281,85 +325,92 @@ export class FlowStore { }; const duplicate = duplicateRows[0]; - if (duplicate && duplicate.status !== "reserved") { + const retryCutoff = new Date(now.getTime() - FLOW_RESERVATION_LEASE_MS); + const isPending = (delivery: DeliveryRow): boolean => + delivery.status === "reserved" || delivery.status === "dispatching"; + const isStale = (delivery: DeliveryRow): boolean => + date(delivery.updated_at).getTime() <= retryCutoff.getTime(); + const failUnknown = async (delivery: DeliveryRow): Promise<DeliveryRow> => { + signal?.throwIfAborted(); + const failedRows = await transaction<DeliveryRow[]>` + UPDATE inspiration_flow_deliveries + SET status = 'failed', + notification_channel = NULL, + notification_channels = NULL, + error = ${FLOW_UNKNOWN_DISPATCH_ERROR}, + version = version + 1, + updated_at = ${now} + WHERE id = ${delivery.id} + AND version = ${delivery.version} + AND status IN ('reserved', 'dispatching') + RETURNING * + `; + signal?.throwIfAborted(); + const failed = failedRows[0]; + if (!failed) { + throw new FlowStoreError( + "VERSION_CONFLICT", + `delivery ${delivery.id} changed during interrupted dispatch recovery`, + 409, + delivery.version + ); + } + return failed; + }; + + if (duplicate) { + if (!isPending(duplicate)) { + return resultForDelivery( + duplicate, + ["dedupe:existing-delivery"], + false + ); + } + if (!isStale(duplicate)) { + return resultForDelivery( + duplicate, + ["dedupe:delivery-in-flight"], + false + ); + } + const failed = await failUnknown(duplicate); return resultForDelivery( - duplicate, - ["dedupe:existing-delivery"], + failed, + ["recovery:interrupted-dispatch-unknown"], false ); } - const settingsRows = await transaction<SettingsRow[]>` - SELECT * FROM inspiration_flow_settings WHERE id = 'default' FOR UPDATE - `; - signal?.throwIfAborted(); - const settingsRow = settingsRows[0]; - if (!settingsRow) throw new Error("inspiration Flow settings are unavailable"); - const settings = mapSettings(settingsRow); - - let pending = duplicate; - if (!pending && source === "scheduled") { + if (source === "scheduled") { const pendingRows = await transaction<DeliveryRow[]>` SELECT * FROM inspiration_flow_deliveries - WHERE source = 'scheduled' AND status = 'reserved' + WHERE source = 'scheduled' + AND status IN ('reserved', 'dispatching') ORDER BY created_at, id LIMIT 1 FOR UPDATE `; signal?.throwIfAborted(); - pending = pendingRows[0]; - } - - if (pending) { - if (source === "scheduled") { - const gateReasons = [ - ...(!settings.enabled ? ["policy:disabled"] : []), - ...(isQuietMinute( - minuteOfLocalDay(now), - settings.quietStartMinute, - settings.quietEndMinute - ) ? ["policy:quiet-hours"] : []), - ]; - if (gateReasons.length > 0) { - return resultForDelivery(pending, gateReasons, false); - } - } - - const retryCutoff = new Date(now.getTime() - FLOW_RESERVATION_LEASE_MS); - if (date(pending.updated_at).getTime() > retryCutoff.getTime()) { + const pending = pendingRows[0]; + if (pending && !isStale(pending)) { return resultForDelivery( pending, ["dedupe:delivery-in-flight"], false ); } - - signal?.throwIfAborted(); - const claimedRows = await transaction<DeliveryRow[]>` - UPDATE inspiration_flow_deliveries - SET attempts = attempts + 1, - version = version + 1, - updated_at = ${now} - WHERE id = ${pending.id} - AND version = ${pending.version} - AND status = 'reserved' - RETURNING * - `; - signal?.throwIfAborted(); - const claimed = claimedRows[0]; - if (!claimed) { - throw new FlowStoreError( - "VERSION_CONFLICT", - `delivery ${pending.id} changed during recovery`, - 409, - pending.version + if (pending) { + // This row may already have crossed the external-send boundary. + // Terminalize it and stop this poll. A later poll may create a new + // bucket delivery through normal policy, but recovery itself never + // cascades into a second external send. + const failed = await failUnknown(pending); + return resultForDelivery( + failed, + ["recovery:interrupted-dispatch-unknown"], + false ); } - return resultForDelivery( - claimed, - ["recovery:pending-delivery"], - true - ); } // Lock the complete local candidate set. This personal-data plugin is @@ -444,7 +495,7 @@ export class FlowStore { id, inspiration_id, source, dedupe_key, status, surfaced_at, created_at, updated_at ) VALUES ( - ${nanoid(12)}, ${current.id}, ${source}, ${dedupeKey}, 'reserved', + ${nanoid(12)}, ${current.id}, ${source}, ${resolvedDedupeKey}, 'reserved', ${now}, ${now}, ${now} ) RETURNING * @@ -465,6 +516,48 @@ export class FlowStore { }); } + async claimNotification( + deliveryId: string, + expectedVersion: number, + now = new Date(), + signal?: AbortSignal + ): Promise<FlowDelivery> { + signal?.throwIfAborted(); + return this.sql.begin(async (transaction) => { + const rows = await transaction<DeliveryRow[]>` + UPDATE inspiration_flow_deliveries + SET status = 'dispatching', + version = version + 1, + updated_at = ${now} + WHERE id = ${deliveryId} + AND version = ${expectedVersion} + AND status = 'reserved' + RETURNING * + `; + signal?.throwIfAborted(); + if (rows[0]) return mapDelivery(rows[0]); + + const currentRows = await transaction<DeliveryRow[]>` + SELECT * FROM inspiration_flow_deliveries WHERE id = ${deliveryId} + `; + signal?.throwIfAborted(); + const current = currentRows[0]; + if (!current) { + throw new FlowStoreError( + "NOT_FOUND", + `delivery ${deliveryId} not found`, + 404 + ); + } + throw new FlowStoreError( + current.version === expectedVersion ? "INVALID_STATE" : "VERSION_CONFLICT", + `delivery ${deliveryId} cannot begin notification dispatch from ${current.status}`, + 409, + current.version + ); + }); + } + async finalizeNotification( deliveryId: string, expectedVersion: number, @@ -479,7 +572,7 @@ export class FlowStore { error = NULL, version = version + 1, updated_at = ${result.at} WHERE id = ${deliveryId} AND version = ${expectedVersion} - AND status = 'reserved' + AND status = 'dispatching' RETURNING * ` : await this.sql<DeliveryRow[]>` @@ -492,7 +585,7 @@ export class FlowStore { error = ${result.error}, version = version + 1, updated_at = ${result.at} WHERE id = ${deliveryId} AND version = ${expectedVersion} - AND status = 'reserved' + AND status = 'dispatching' RETURNING * `; if (rows[0]) return mapDelivery(rows[0]); @@ -514,21 +607,31 @@ export class FlowStore { async listDeliveries( limit = 50, - before?: Date - ): Promise<FlowDelivery[]> { - const rows = before + cursor?: DeliveryCursor + ): Promise<FlowDeliveryPage> { + const queryLimit = limit + 1; + const rows = cursor ? await this.sql<DeliveryRow[]>` SELECT * FROM inspiration_flow_deliveries - WHERE surfaced_at < ${before} + WHERE (surfaced_at < ${cursor.surfacedAt}) + OR (surfaced_at = ${cursor.surfacedAt} AND id < ${cursor.id}) ORDER BY surfaced_at DESC, id DESC - LIMIT ${limit} + LIMIT ${queryLimit} ` : await this.sql<DeliveryRow[]>` SELECT * FROM inspiration_flow_deliveries ORDER BY surfaced_at DESC, id DESC - LIMIT ${limit} + LIMIT ${queryLimit} `; - return rows.map(mapDelivery); + const hasMore = rows.length > limit; + const deliveries = rows.slice(0, limit).map(mapDelivery); + const last = hasMore ? deliveries.at(-1) : undefined; + return { + deliveries, + nextCursor: last + ? { surfacedAt: last.surfacedAt, id: last.id } + : null, + }; } async applyOutcome( @@ -578,10 +681,10 @@ export class FlowStore { inspiration.version ); } - if (delivery.status !== "sent") { + if (!canApplyFlowOutcome(delivery)) { throw new FlowStoreError( "INVALID_STATE", - `delivery ${deliveryId} is ${delivery.status}, not sent`, + `delivery ${deliveryId} is not actionable from ${delivery.status}/${delivery.source}`, 409, delivery.version, inspiration.version diff --git a/plugins/inspiration/src/flow.ts b/plugins/inspiration/src/flow.ts index ff14ef4..7c6485b 100644 --- a/plugins/inspiration/src/flow.ts +++ b/plugins/inspiration/src/flow.ts @@ -5,10 +5,13 @@ import { type PluginNotificationResult, } from "@echolog/plugin-sdk"; import type { + FlowDeliveryPage, FlowNotificationFinalization, FlowOutcomeResult, FlowReserveResult, } from "./flow-store.js"; +export { scheduledFlowDedupeKey } from "./flow-store.js"; +import type { DeliveryCursor } from "./pagination.js"; import { sendFlowNotification, type NotificationsSendProvider, @@ -24,14 +27,6 @@ import type { export const FLOW_JOB_POLL_MS = 60_000; export const FLOW_JOB_TIMEOUT_MS = 30_000; -export function scheduledFlowDedupeKey( - now: Date, - intervalMinutes: number -): string { - const intervalMs = intervalMinutes * 60_000; - return `scheduled:${intervalMinutes}:${Math.floor(now.getTime() / intervalMs)}`; -} - function manualFlowDedupeKey(idempotencyKey?: string): string { return `manual:${idempotencyKey ?? nanoid(20)}`; } @@ -63,21 +58,42 @@ function noDeliveryMessage(result: PluginNotificationResult): string { : "notifications.send failed on all enabled channels"; } +function explainFailedDelivery(result: FlowReserveResult): void { + const reason = "delivery:failed"; + if (!result.explanation.includes(reason)) result.explanation.push(reason); + if ( + result.candidate && + result.candidate.explanation !== result.explanation && + !result.candidate.explanation.includes(reason) + ) { + result.candidate.explanation.push(reason); + } +} + export interface FlowPersistence { getSettings(): Promise<FlowSettings>; updateSettings(input: FlowSettingsUpdate): Promise<FlowSettings | null>; reserveNext( source: "manual" | "scheduled", - dedupeKey: string, + dedupeKey: string | undefined, now?: Date, signal?: AbortSignal ): Promise<FlowReserveResult>; + claimNotification( + deliveryId: string, + expectedVersion: number, + now?: Date, + signal?: AbortSignal + ): Promise<FlowDelivery>; finalizeNotification( deliveryId: string, expectedVersion: number, result: FlowNotificationFinalization ): Promise<FlowDelivery>; - listDeliveries(limit?: number, before?: Date): Promise<FlowDelivery[]>; + listDeliveries( + limit?: number, + cursor?: DeliveryCursor + ): Promise<FlowDeliveryPage>; applyOutcome( deliveryId: string, expectedDeliveryVersion: number, @@ -119,18 +135,12 @@ export class FlowService { async runScheduled(signal: AbortSignal): Promise<FlowReserveResult> { signal.throwIfAborted(); const now = this.clock(); - const settings = await this.store.getSettings(); - return this.deliver( - "scheduled", - scheduledFlowDedupeKey(now, settings.intervalMinutes), - now, - signal - ); + return this.deliver("scheduled", undefined, now, signal); } private async deliver( source: "manual" | "scheduled", - dedupeKey: string, + dedupeKey: string | undefined, now: Date, signal?: AbortSignal ): Promise<FlowReserveResult> { @@ -143,10 +153,25 @@ export class FlowService { const candidate = reserved.candidate; if (!candidate) return reserved; - // The store atomically decides whether this caller owns the notification - // attempt. Existing/freshly in-flight duplicates remain observable without - // causing another send; stale reservations are claimed across restarts. - if (!reserved.shouldNotify) return reserved; + // The store atomically decides whether this caller owns a new reservation. + // Existing or stale pending rows remain observable without another send. + if (!reserved.shouldNotify) { + if (candidate.delivery.status === "failed") { + explainFailedDelivery(reserved); + } + return reserved; + } + + // Cross an explicit durable boundary before invoking Core. A row that is + // left dispatching has an unknown external outcome and is never reclaimed + // for another send. The request carries a stable dedupe hint, but providers + // may ignore it, so the ledger still enforces same-row at-most-once. + candidate.delivery = await this.store.claimNotification( + candidate.delivery.id, + candidate.delivery.version, + this.clock(), + signal + ); let notification; try { @@ -158,9 +183,9 @@ export class FlowService { ); signal?.throwIfAborted(); } catch (error) { - // Preserve a reserved row on cancellation. The Host's rejecting timeout - // releases its non-reentry guard, and the next identical bucket can - // safely resume with the notification dedupe key after restart/timeout. + // Preserve dispatching on cancellation. The call may already have + // reached an external channel, so restart recovery must diagnose the row + // as unknown rather than invoke notifications.send again. if (signal?.aborted || isAbortError(error)) throw error; candidate.delivery = await this.store.finalizeNotification( candidate.delivery.id, @@ -175,6 +200,7 @@ export class FlowService { at: this.clock(), } ); + explainFailedDelivery(reserved); return reserved; } @@ -194,11 +220,14 @@ export class FlowService { at: this.clock(), } ); + if (candidate.delivery.status === "failed") { + explainFailedDelivery(reserved); + } return reserved; } - listDeliveries(limit?: number, before?: Date) { - return this.store.listDeliveries(limit, before); + listDeliveries(limit?: number, cursor?: DeliveryCursor) { + return this.store.listDeliveries(limit, cursor); } async applyOutcome( diff --git a/plugins/inspiration/src/http-validation.ts b/plugins/inspiration/src/http-validation.ts new file mode 100644 index 0000000..98074cd --- /dev/null +++ b/plugins/inspiration/src/http-validation.ts @@ -0,0 +1,54 @@ +const OFFSET_AWARE_ISO_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/; + +function daysInMonth(year: number, month: number): number { + if (month === 2) { + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + return leap ? 29 : 28; + } + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +/** + * Parse an ISO 8601 timestamp only when its timezone is explicit. + * + * This intentionally does not use a host-local fallback: callers must provide + * either `Z` or a numeric `±HH:mm` offset. Calendar and offset components are + * checked before constructing the Date so values such as February 30 or + * `+24:00` cannot be normalized into a different instant. + */ +export function parseOffsetAwareIso(value: string): Date | null { + const match = OFFSET_AWARE_ISO_RE.exec(value); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === "Z" ? 0 : Number(match[10]); + const offsetMinute = match[8] === "Z" ? 0 : Number(match[11]); + + if ( + year === 0 || + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 59 || + offsetHour > 23 || + offsetMinute > 59 + ) { + return null; + } + + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) ? parsed : null; +} + +export function isOffsetAwareIso(value: unknown): value is string { + return typeof value === "string" && parseOffsetAwareIso(value) !== null; +} diff --git a/plugins/inspiration/src/migrations.ts b/plugins/inspiration/src/migrations.ts index 9927a03..356925e 100644 --- a/plugins/inspiration/src/migrations.ts +++ b/plugins/inspiration/src/migrations.ts @@ -138,4 +138,14 @@ export const migrations: PluginMigration[] = [ ADD COLUMN IF NOT EXISTS notification_channels JSONB; `, }, + { + name: "006_inspiration_flow_dispatching_status", + sql: ` + ALTER TABLE inspiration_flow_deliveries + DROP CONSTRAINT IF EXISTS inspiration_flow_deliveries_status_check; + ALTER TABLE inspiration_flow_deliveries + ADD CONSTRAINT inspiration_flow_deliveries_status_check + CHECK (status IN ('reserved', 'dispatching', 'sent', 'failed', 'acted')); + `, + }, ]; diff --git a/plugins/inspiration/src/notifications.ts b/plugins/inspiration/src/notifications.ts index ec6f5b9..98111e5 100644 --- a/plugins/inspiration/src/notifications.ts +++ b/plugins/inspiration/src/notifications.ts @@ -59,10 +59,13 @@ export function sendFlowNotification( signal?: AbortSignal ): Promise<PluginNotificationResult> { signal?.throwIfAborted(); + // The ledger key is stable and unique per delivery. Prefix it to keep Core's + // transport-level dedupe namespace independent from other bundled plugins. return provider()( { title: "Inspiration", message: candidate.inspiration.content, + dedupeKey: `inspiration:${candidate.delivery.dedupeKey}`, }, signal ).then(projectNotificationResult); diff --git a/plugins/inspiration/src/pagination.ts b/plugins/inspiration/src/pagination.ts new file mode 100644 index 0000000..844243f --- /dev/null +++ b/plugins/inspiration/src/pagination.ts @@ -0,0 +1,47 @@ +import { parseOffsetAwareIso } from "./http-validation.js"; + +const DELIVERY_CURSOR_ID_RE = /^[A-Za-z0-9_-]{1,80}$/; + +export interface DeliveryCursor { + surfacedAt: Date; + id: string; +} + +export function encodeDeliveryCursor(value: { + surfacedAt: Date; + id: string; +}): string { + if ( + !Number.isFinite(value.surfacedAt.getTime()) || + !DELIVERY_CURSOR_ID_RE.test(value.id) + ) { + throw new TypeError("delivery cursor is invalid"); + } + return Buffer.from(JSON.stringify({ + surfacedAt: value.surfacedAt.toISOString(), + id: value.id, + })).toString("base64url"); +} + +export function decodeDeliveryCursor(value: string): DeliveryCursor | null { + if (!/^[A-Za-z0-9_-]+$/.test(value)) return null; + try { + const decoded: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + return null; + } + const record = decoded as Record<string, unknown>; + if ( + Object.keys(record).length !== 2 || + typeof record.surfacedAt !== "string" || + typeof record.id !== "string" || + !DELIVERY_CURSOR_ID_RE.test(record.id) + ) { + return null; + } + const surfacedAt = parseOffsetAwareIso(record.surfacedAt); + return surfacedAt ? { surfacedAt, id: record.id } : null; + } catch { + return null; + } +} diff --git a/plugins/inspiration/src/routes.ts b/plugins/inspiration/src/routes.ts index 1b97d45..84e2891 100644 --- a/plugins/inspiration/src/routes.ts +++ b/plugins/inspiration/src/routes.ts @@ -9,6 +9,7 @@ import { type InspirationPage, type InspirationStoreListFilter, } from "./store.js"; +import { parseOffsetAwareIso } from "./http-validation.js"; import type { CreateInspirationInput, Inspiration, @@ -284,13 +285,13 @@ function repeatedQueryStrings( } function isoDate(value: string, name: string): ValidationResult<Date> { - if (!value.includes("T")) { - return { ok: false, error: `${name} must be an ISO 8601 timestamp` }; - } - const date = new Date(value); - return Number.isFinite(date.getTime()) + const date = parseOffsetAwareIso(value); + return date ? { ok: true, value: date } - : { ok: false, error: `${name} must be an ISO 8601 timestamp` }; + : { + ok: false, + error: `${name} must be an ISO 8601 timestamp with Z or ±HH:mm offset`, + }; } function validateList(query: unknown): ValidationResult<InspirationStoreListFilter> { diff --git a/plugins/inspiration/src/schema.ts b/plugins/inspiration/src/schema.ts index 0177db8..bc0403b 100644 --- a/plugins/inspiration/src/schema.ts +++ b/plugins/inspiration/src/schema.ts @@ -160,7 +160,7 @@ export const inspirationFlowDeliveries = pgTable( ), check( "inspiration_flow_deliveries_status_check", - sql`${table.status} IN ('reserved', 'sent', 'failed', 'acted')` + sql`${table.status} IN ('reserved', 'dispatching', 'sent', 'failed', 'acted')` ), check( "inspiration_flow_deliveries_outcome_check", diff --git a/plugins/inspiration/src/types.ts b/plugins/inspiration/src/types.ts index 5db0d6e..1e8db41 100644 --- a/plugins/inspiration/src/types.ts +++ b/plugins/inspiration/src/types.ts @@ -41,7 +41,12 @@ export interface InspirationListFilter { } export type FlowSource = "manual" | "scheduled"; -export type FlowDeliveryStatus = "reserved" | "sent" | "failed" | "acted"; +export type FlowDeliveryStatus = + | "reserved" + | "dispatching" + | "sent" + | "failed" + | "acted"; export type FlowOutcome = | "viewed" | "continued" diff --git a/plugins/inspiration/tsup.config.ts b/plugins/inspiration/tsup.config.ts index 7c1e5c2..9d1268a 100644 --- a/plugins/inspiration/tsup.config.ts +++ b/plugins/inspiration/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/cli.ts"], + entry: ["src/index.ts", "src/cli.ts", "src/http-validation.ts"], outDir: "dist", format: "esm", dts: true, diff --git a/plugins/inspiration/web/index.js b/plugins/inspiration/web/index.js index c9fcc9f..0496c22 100644 --- a/plugins/inspiration/web/index.js +++ b/plugins/inspiration/web/index.js @@ -1,5 +1,228 @@ const API_PREFIX = "/plugins/inspiration"; +const INSPIRATION_STYLES = `<style> + .inspiration-shell { + --ins-line: rgba(90, 74, 58, 0.2); + --ins-line-strong: rgba(176, 58, 46, 0.34); + --ins-wash: rgba(255, 255, 255, 0.28); + --ins-wash-strong: rgba(255, 255, 255, 0.48); + container-type: inline-size; + gap: 0.72rem !important; + color: var(--ink); + } + .inspiration-shell *, .inspiration-shell *::before, .inspiration-shell *::after { box-sizing: border-box; } + .inspiration-shell p, .inspiration-shell h2 { text-wrap: pretty; } + .inspiration-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.7rem; + border-bottom: 1px solid var(--ins-line); + } + .inspiration-hero-copy { min-width: 0; } + .inspiration-kicker { + display: block; + margin-bottom: 0.22rem; + color: var(--cinnabar); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + } + .inspiration-hero .toc-title { margin: 0; } + .inspiration-summary { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.34rem; + } + .inspiration-summary span, + .inspiration-status, + .inspiration-chip { + display: inline-flex; + align-items: center; + min-height: 1.55rem; + padding: 0.2rem 0.58rem; + border: 1px solid var(--ins-line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + color: var(--ink-soft); + font-size: 0.64rem; + line-height: 1; + } + .inspiration-summary b { color: var(--cinnabar); font-size: 0.74rem; } + .inspiration-composer, + .inspiration-card, + .inspiration-settings, + .inspiration-history { + border: 1px solid var(--ins-line); + border-radius: 0.8rem; + background: var(--ins-wash); + box-shadow: 0 0.5rem 1.5rem rgba(43, 33, 24, 0.055); + } + .inspiration-composer { padding: 0.72rem; } + .inspiration-composer .form-input:first-of-type { + min-height: 5.25rem; + border-color: transparent; + background: var(--ins-wash-strong); + font-family: var(--kai); + font-size: 1rem; + line-height: 1.7; + } + .inspiration-composer .form-input:first-of-type:focus { border-color: var(--cinnabar); } + .inspiration-composer-foot, + .inspiration-card-actions, + .inspiration-outcomes { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.45rem; + } + .inspiration-filter-panel { margin: 0; border-bottom: 1px solid var(--ins-line); } + .inspiration-filter-panel > summary, + .inspiration-settings > summary { + min-height: 2.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + cursor: pointer; + color: var(--ink-soft); + font-size: 0.72rem; + list-style: none; + } + .inspiration-filter-panel > summary::-webkit-details-marker, + .inspiration-settings > summary::-webkit-details-marker { display: none; } + .inspiration-filter-panel > summary::after, + .inspiration-settings > summary::after { content: "+"; color: var(--cinnabar); font-size: 1rem; } + .inspiration-filter-panel[open] > summary::after, + .inspiration-settings[open] > summary::after { content: "−"; } + .inspiration-filter-body { padding: 0 0 0.72rem; } + .inspiration-checks { display: flex; flex-wrap: wrap; gap: 0.5rem 0.8rem; margin-top: 0.5rem; } + .inspiration-checks label { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.7rem; color: var(--ink-soft); } + .inspiration-card-list { display: grid; gap: 0.62rem; } + .inspiration-card { position: relative; overflow: hidden; padding: 0.8rem; } + .inspiration-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 2px; + background: var(--gold); + opacity: 0.55; + } + .inspiration-card[data-status="kept"]::before { background: var(--pine); opacity: 0.9; } + .inspiration-card[data-status="archived"]::before { background: var(--ink-faint); } + .inspiration-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + margin-bottom: 0.5rem; + } + .inspiration-card-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.32rem; min-width: 0; } + .inspiration-status { color: var(--cinnabar); border-color: var(--ins-line-strong); font-weight: 700; } + .inspiration-card[data-status="kept"] .inspiration-status { color: var(--pine); border-color: rgba(61, 92, 69, 0.34); } + .inspiration-card[data-status="archived"] .inspiration-status { color: var(--ink-faint); border-color: var(--ins-line); } + .inspiration-version { color: var(--ink-faint); font-size: 0.6rem; letter-spacing: 0.08em; white-space: nowrap; } + .inspiration-card textarea { resize: vertical; font-family: var(--kai); line-height: 1.65; } + .inspiration-card-copy { margin: 0.25rem 0 0.6rem; color: var(--ink); font-family: var(--kai); line-height: 1.7; } + .inspiration-tags { display: flex; flex-wrap: wrap; gap: 0.28rem; min-height: 1.5rem; } + .inspiration-chip { color: var(--pine); background: rgba(61, 92, 69, 0.07); } + .inspiration-card-actions { margin-top: 0.58rem; padding-top: 0.56rem; border-top: 1px solid var(--ins-line); } + .inspiration-card-actions button, + .inspiration-outcomes button, + .inspiration-history-foot button, + .inspiration-filter-body button { + min-height: 2.35rem; + padding: 0.45rem 0.8rem; + border: 1px solid var(--ins-line); + border-radius: 0.55rem; + color: var(--ink-soft); + background: rgba(255, 255, 255, 0.24); + cursor: pointer; + font: inherit; + font-size: 0.7rem; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; + } + .inspiration-card-actions button:hover, + .inspiration-outcomes button:hover, + .inspiration-history-foot button:hover, + .inspiration-filter-body button:hover { transform: translateY(-1px); border-color: var(--cinnabar); } + .inspiration-card-actions button:focus-visible, + .inspiration-outcomes button:focus-visible, + .inspiration-history-foot button:focus-visible, + .inspiration-filter-body button:focus-visible { outline: 2px solid var(--cinnabar); outline-offset: 2px; } + .inspiration-card-actions .inspiration-primary, + .inspiration-outcomes .inspiration-primary { color: var(--paper); border-color: var(--cinnabar); background: var(--cinnabar); } + .inspiration-card-actions .inspiration-danger { color: var(--cinnabar); } + .inspiration-empty { padding: 1.4rem; text-align: center; border: 1px dashed var(--ins-line); border-radius: 0.8rem; } + .inspiration-flow-layout { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(9rem, 0.8fr); gap: 0.7rem; min-height: 0; } + .inspiration-flow-stage { min-width: 0; } + .inspiration-flow-toolbar { display: flex; justify-content: flex-end; margin-bottom: 0.55rem; } + .inspiration-flow-candidate { min-height: 14rem; display: flex; flex-direction: column; justify-content: space-between; } + .inspiration-flow-candidate .inspiration-card-copy { font-size: 1.08rem; } + .inspiration-explanation { margin: 0.5rem 0; color: var(--ink-faint); font-size: 0.68rem; line-height: 1.55; } + .inspiration-outcomes { justify-content: flex-start; padding-top: 0.62rem; border-top: 1px solid var(--ins-line); } + .inspiration-outcome-note { margin-top: 0.65rem; padding: 0.65rem; border: 1px dashed var(--ins-line); border-radius: 0.6rem; color: var(--ink-faint); font-size: 0.68rem; line-height: 1.55; } + .inspiration-history { min-width: 0; padding: 0.7rem; } + .inspiration-history-title { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; margin-bottom: 0.48rem; } + .inspiration-history-title strong { font-family: var(--kai); font-size: 0.86rem; } + .inspiration-history-title span { color: var(--ink-faint); font-size: 0.6rem; } + .inspiration-history-list { display: grid; gap: 0.18rem; max-height: 21rem; overflow-y: auto; } + .inspiration-history-row { + display: grid; + grid-template-columns: 0.42rem minmax(0, 1fr); + gap: 0.48rem; + padding: 0.5rem 0.28rem; + border-bottom: 1px solid var(--ins-line); + } + .inspiration-history-row::before { content: ""; width: 0.38rem; height: 0.38rem; margin-top: 0.18rem; border-radius: 50%; background: var(--gold); } + .inspiration-history-row[data-status="failed"]::before { background: var(--cinnabar); } + .inspiration-history-row[data-status="sent"]::before { background: var(--pine); } + .inspiration-history-row strong { display: block; overflow: hidden; color: var(--ink-soft); font-size: 0.66rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } + .inspiration-history-row time { color: var(--ink-faint); font-size: 0.58rem; } + .inspiration-history-foot { margin-top: 0.55rem; } + .inspiration-settings { margin-top: 0.7rem; padding: 0 0.72rem 0.72rem; } + .inspiration-settings .rule-form { margin: 0; border: 0; background: transparent; box-shadow: none; } + @container (max-width: 34rem) { + .inspiration-hero { align-items: flex-start; flex-direction: column; } + .inspiration-summary { justify-content: flex-start; } + .inspiration-flow-layout { grid-template-columns: 1fr; } + .inspiration-history-list { max-height: 12rem; } + .inspiration-composer-foot { align-items: stretch; flex-direction: column; } + .inspiration-card-actions button, .inspiration-outcomes button { flex: 1 1 7rem; } + } + @media (prefers-reduced-motion: reduce) { + .inspiration-shell * { transition-duration: 0.01ms !important; } + } +</style>`; + +function renderShell(kind, content) { + return `${INSPIRATION_STYLES}<div class="leaf-inner toc-face inspiration-shell inspiration-${kind}-face">${content}</div>`; +} + +function inspirationStatusLabel(status) { + return ({ inbox: "收件箱", kept: "保留", archived: "已归档" })[status] ?? String(status ?? "未知"); +} + +function deliveryStateLabel(delivery) { + if (delivery?.status === "failed") { + return delivery.source === "manual" ? "手动投递失败(未展示)" : "定时投递失败(未展示)"; + } + const status = ({ reserved: "待投递", dispatching: "投递中", sent: "已展示" })[delivery?.status] + ?? String(delivery?.status ?? "未知"); + const outcome = ({ + viewed: "已查看", + continued: "继续编辑", + kept: "已保留", + later: "稍后再看", + archived: "已归档", + })[delivery?.outcome]; + return outcome ? `${status} · ${outcome}` : status; +} + function csv(value) { return String(value ?? "") .split(/[,,]/) @@ -27,7 +250,36 @@ function listPath(filters) { return `${API_PREFIX}/inspirations?${params}`; } -export async function activate({ api }) { +function isActionableDelivery(delivery) { + return Boolean( + delivery && + delivery.source === "manual" && + delivery.status === "sent" && + delivery.outcome == null + ); +} + +function inspirationSnapshotSignature(items) { + return JSON.stringify(Array.isArray(items) ? items : []); +} + +function deliverySnapshotSignature(deliveries, nextCursor) { + return JSON.stringify([ + Array.isArray(deliveries) ? deliveries : [], + typeof nextCursor === "string" ? nextCursor : null, + ]); +} + +function isHostEditing(root) { + const active = root?.ownerDocument?.activeElement; + return Boolean( + active && + /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName) && + active.closest?.("#pages") + ); +} + +export async function activate({ api, refresh, root }) { let filters = { text: "", tags: [], @@ -38,17 +290,61 @@ export async function activate({ api }) { let latestInspirations = []; let latestSettings = null; let latestDeliveries = []; + let latestDeliveryNextCursor = null; let currentCandidate = null; + let mounted = true; + let lifecycleVersion = 0; + let liveRequestVersion = 0; + let refreshInFlight = null; + let presentedInboxSignature = inspirationSnapshotSignature([]); + let presentedFlowSignature = deliverySnapshotSignature([], null); + + const signaturesFor = (inspirations, deliveries, nextCursor) => ({ + inbox: inspirationSnapshotSignature(inspirations), + flow: deliverySnapshotSignature(deliveries, nextCursor), + }); + + const applyLiveSnapshot = (inspirations, deliveries, nextCursor) => { + latestInspirations = inspirations; + latestDeliveries = deliveries; + latestDeliveryNextCursor = nextCursor; + if (currentCandidate) { + const currentDelivery = deliveries.find( + (delivery) => delivery.id === currentCandidate.delivery.id + ); + const currentInspiration = inspirations.find( + (inspiration) => inspiration.id === currentCandidate.inspiration.id + ); + currentCandidate = { + ...currentCandidate, + inspiration: currentInspiration ?? currentCandidate.inspiration, + delivery: currentDelivery ?? currentCandidate.delivery, + }; + } + }; async function loadSnapshot() { + if (!mounted) return {}; + const expectedLifecycleVersion = lifecycleVersion; const [list, settings, ledger] = await Promise.all([ api(listPath(filters)), api(`${API_PREFIX}/flow/settings`), api(`${API_PREFIX}/flow/deliveries?limit=20`), ]); + if (!mounted || expectedLifecycleVersion !== lifecycleVersion) return {}; latestInspirations = Array.isArray(list?.items) ? list.items : []; latestSettings = settings; latestDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : []; + latestDeliveryNextCursor = typeof ledger?.nextCursor === "string" + ? ledger.nextCursor + : null; + const signatures = signaturesFor( + latestInspirations, + latestDeliveries, + latestDeliveryNextCursor + ); + presentedInboxSignature = signatures.inbox; + presentedFlowSignature = signatures.flow; return { inspirationList: list, inspirationFlowSettings: settings, @@ -68,116 +364,215 @@ export async function activate({ api }) { }, load: loadSnapshot, async loadLive() { + if (!mounted) return {}; + if (refreshInFlight) { + try { + await refreshInFlight; + } catch { + // The initiating poll reports the Host refresh failure. A later poll + // retries from a fresh server snapshot instead of joining a rebuild. + } + if (!mounted) return {}; + } + const expectedLifecycleVersion = lifecycleVersion; + const requestVersion = ++liveRequestVersion; const [list, ledger] = await Promise.all([ api(listPath(filters)), api(`${API_PREFIX}/flow/deliveries?limit=20`), ]); - latestInspirations = Array.isArray(list?.items) ? list.items : []; - latestDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : []; - return { + if ( + !mounted || + expectedLifecycleVersion !== lifecycleVersion || + requestVersion !== liveRequestVersion + ) return {}; + const nextInspirations = Array.isArray(list?.items) ? list.items : []; + const nextDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : []; + const nextDeliveryNextCursor = typeof ledger?.nextCursor === "string" + ? ledger.nextCursor + : null; + const liveData = { inspirationList: list, inspirationFlowDeliveries: ledger, }; + const signatures = signaturesFor( + nextInspirations, + nextDeliveries, + nextDeliveryNextCursor + ); + const snapshotChanged = signatures.inbox !== presentedInboxSignature + || signatures.flow !== presentedFlowSignature; + const canInvalidateHost = typeof refresh === "function"; + if (snapshotChanged && canInvalidateHost && isHostEditing(root)) { + return liveData; + } + applyLiveSnapshot(nextInspirations, nextDeliveries, nextDeliveryNextCursor); + if ( + snapshotChanged && + canInvalidateHost + ) { + const previousInboxSignature = presentedInboxSignature; + const previousFlowSignature = presentedFlowSignature; + const refreshPromise = Promise.resolve().then(() => refresh({})); + refreshInFlight = refreshPromise; + try { + await refreshPromise; + if (mounted && expectedLifecycleVersion === lifecycleVersion) { + // The real Host refresh calls loadSnapshot(), which advances these + // signatures to the data it rendered. Lightweight hosts/tests may + // only honor the invalidation callback, so record its target when + // no full load occurred. + if ( + presentedInboxSignature === previousInboxSignature && + presentedFlowSignature === previousFlowSignature + ) { + presentedInboxSignature = signatures.inbox; + presentedFlowSignature = signatures.flow; + } + } + } finally { + if (refreshInFlight === refreshPromise) refreshInFlight = null; + } + } + return mounted && expectedLifecycleVersion === lifecycleVersion ? liveData : {}; }, renderFace(face, { esc, escA }) { if (face.type === "inspiration-inbox") { const rows = latestInspirations.map((item) => { const tags = Array.isArray(item.tags) - ? item.tags.map((tag) => `<span class="type-chip">#${esc(tag)}</span>`).join(" ") + ? item.tags.map((tag) => `<span class="inspiration-chip">#${esc(tag)}</span>`).join("") : ""; - const metadata = [item.project, item.status, `v${item.version}`] - .filter(Boolean) - .map((value) => esc(value)) - .join(" · "); + const project = item.project ?? "未分项目"; + const cardHead = `<header class="inspiration-card-head"> + <div class="inspiration-card-meta"> + <span class="inspiration-status">${esc(inspirationStatusLabel(item.status))}</span> + <span class="inspiration-chip">${esc(project)}</span> + </div> + <span class="inspiration-version">v${esc(item.version)}</span> + </header>`; if (item.status === "archived") { - return `<article class="rule-form inspiration-row"> - <div class="toc-section">${metadata}</div> - <p>${esc(item.content)}</p> - <div class="form-hint">${tags}</div> - <div class="rule-form-foot"> - <button type="button" data-act="restore-inspiration" data-id="${escA(item.id)}">恢复到收件箱</button> + return `<article class="inspiration-card" data-status="archived"> + ${cardHead} + <p class="inspiration-card-copy">${esc(item.content)}</p> + <div class="inspiration-tags">${tags}</div> + <div class="inspiration-card-actions"> + <button class="inspiration-primary" type="button" data-act="restore-inspiration" data-id="${escA(item.id)}">恢复到收件箱</button> </div> </article>`; } - return `<article class="rule-form inspiration-row"> - <div class="toc-section">${metadata}</div> - <textarea class="form-input" id="inspirationContent:${escA(item.id)}" rows="3">${escA(item.content)}</textarea> + return `<article class="inspiration-card" data-status="${escA(item.status)}"> + ${cardHead} + <textarea class="form-input" id="inspirationContent:${escA(item.id)}" rows="3" aria-label="编辑灵感正文">${escA(item.content)}</textarea> <div class="rule-form-grid"> - <input class="form-input" id="inspirationTags:${escA(item.id)}" type="text" value="${escA((item.tags ?? []).join(","))}" placeholder="标签,逗号分隔" /> - <input class="form-input" id="inspirationProject:${escA(item.id)}" type="text" value="${escA(item.project ?? "")}" placeholder="项目(可空)" /> - <select class="form-input" id="inspirationStatus:${escA(item.id)}"> + <input class="form-input" id="inspirationTags:${escA(item.id)}" type="text" value="${escA((item.tags ?? []).join(","))}" placeholder="标签,逗号分隔" aria-label="灵感标签" /> + <input class="form-input" id="inspirationProject:${escA(item.id)}" type="text" value="${escA(item.project ?? "")}" placeholder="项目(可空)" aria-label="灵感项目" /> + <select class="form-input" id="inspirationStatus:${escA(item.id)}" aria-label="灵感状态"> <option value="inbox" ${item.status === "inbox" ? "selected" : ""}>收件箱</option> <option value="kept" ${item.status === "kept" ? "selected" : ""}>保留</option> </select> </div> - <div class="form-hint">${tags}</div> + <div class="inspiration-tags">${tags}</div> <div class="form-error" id="inspirationError:${escA(item.id)}"></div> - <div class="rule-form-foot"> - <button type="button" data-act="edit-inspiration" data-id="${escA(item.id)}">保存整理</button> - <button class="rule-del" type="button" data-act="archive-inspiration" data-id="${escA(item.id)}">归档</button> + <div class="inspiration-card-actions"> + <button class="inspiration-primary" type="button" data-act="edit-inspiration" data-id="${escA(item.id)}">保存整理</button> + <button class="inspiration-danger" type="button" data-act="archive-inspiration" data-id="${escA(item.id)}">归档</button> </div> </article>`; }).join(""); - return `<div class="leaf-inner toc-face inspiration-inbox-face"> - <div class="toc-title">灵感收件箱</div> - <div class="rule-form"> - <textarea class="form-input" id="inspirationNewContent" rows="3" placeholder="此刻想到什么?"></textarea> + const keptCount = latestInspirations.filter((item) => item.status === "kept").length; + const archivedCount = latestInspirations.filter((item) => item.status === "archived").length; + return renderShell("inbox", ` + <header class="inspiration-hero"> + <div class="inspiration-hero-copy"> + <span class="inspiration-kicker">INSPIRATION / INBOX</span> + <div class="toc-title">灵感收件箱</div> + </div> + <div class="inspiration-summary" aria-label="当前灵感统计"> + <span><b>${esc(latestInspirations.length)}</b> 当前结果</span> + <span><b>${esc(keptCount)}</b> 已保留</span> + ${filters.includeArchived ? `<span><b>${esc(archivedCount)}</b> 已归档</span>` : ""} + </div> + </header> + <section class="inspiration-composer" aria-label="快速捕捉灵感"> + <span class="inspiration-kicker">快速捕捉</span> + <textarea class="form-input" id="inspirationNewContent" rows="3" placeholder="此刻想到什么?" aria-label="新灵感正文"></textarea> <div class="rule-form-grid"> - <input class="form-input" id="inspirationNewTags" type="text" placeholder="标签,逗号分隔" /> - <input class="form-input" id="inspirationNewProject" type="text" placeholder="项目(可空)" /> + <input class="form-input" id="inspirationNewTags" type="text" placeholder="标签,逗号分隔" aria-label="新灵感标签" /> + <input class="form-input" id="inspirationNewProject" type="text" placeholder="项目(可空)" aria-label="新灵感项目" /> </div> <div class="form-error" id="inspirationNewError"></div> - <div class="rule-form-foot"> + <div class="inspiration-composer-foot"> + <span class="form-hint">可以稍后再整理标签和项目。</span> <button class="seal-btn" type="button" data-act="capture-inspiration"><span class="s-face">记</span><span class="s-label">捕捉</span></button> </div> - </div> - <div class="rule-form"> - <div class="rule-form-grid"> - <input class="form-input" id="inspirationFilterText" type="search" value="${escA(filters.text)}" placeholder="搜索正文" /> - <input class="form-input" id="inspirationFilterTags" type="text" value="${escA(filters.tags.join(","))}" placeholder="标签,逗号分隔" /> - <input class="form-input" id="inspirationFilterProject" type="text" value="${escA(filters.project)}" placeholder="项目" /> - <label><input id="inspirationFilterInbox" type="checkbox" ${filters.statuses.includes("inbox") ? "checked" : ""} /> 收件箱</label> - <label><input id="inspirationFilterKept" type="checkbox" ${filters.statuses.includes("kept") ? "checked" : ""} /> 保留</label> - <label><input id="inspirationFilterArchived" type="checkbox" ${filters.statuses.includes("archived") ? "checked" : ""} /> 已归档</label> - <label><input id="inspirationIncludeArchived" type="checkbox" ${filters.includeArchived ? "checked" : ""} /> 查询归档历史</label> - </div> - <div class="rule-form-foot"> - <button type="button" data-act="filter-inspirations">筛选</button> - <button type="button" data-act="clear-inspiration-filters">清除筛选</button> + </section> + <details class="inspiration-filter-panel" ${filters.text || filters.tags.length || filters.project || filters.includeArchived ? "open" : ""}> + <summary><span>搜索与筛选</span><span>${filters.statuses.map(inspirationStatusLabel).map(esc).join(" · ") || "全部状态"}</span></summary> + <div class="inspiration-filter-body"> + <div class="rule-form-grid"> + <input class="form-input" id="inspirationFilterText" type="search" value="${escA(filters.text)}" placeholder="搜索正文" aria-label="搜索灵感正文" /> + <input class="form-input" id="inspirationFilterTags" type="text" value="${escA(filters.tags.join(","))}" placeholder="标签,逗号分隔" aria-label="按标签筛选" /> + <input class="form-input" id="inspirationFilterProject" type="text" value="${escA(filters.project)}" placeholder="项目" aria-label="按项目筛选" /> + </div> + <div class="inspiration-checks"> + <label><input id="inspirationFilterInbox" type="checkbox" ${filters.statuses.includes("inbox") ? "checked" : ""} /> 收件箱</label> + <label><input id="inspirationFilterKept" type="checkbox" ${filters.statuses.includes("kept") ? "checked" : ""} /> 保留</label> + <label><input id="inspirationFilterArchived" type="checkbox" ${filters.statuses.includes("archived") ? "checked" : ""} /> 已归档</label> + <label><input id="inspirationIncludeArchived" type="checkbox" ${filters.includeArchived ? "checked" : ""} /> 查询归档历史</label> + </div> + <div class="inspiration-card-actions"> + <button class="inspiration-primary" type="button" data-act="filter-inspirations">应用筛选</button> + <button type="button" data-act="clear-inspiration-filters">清除筛选</button> + </div> </div> - </div> - <div class="toc-scroll">${rows || '<p class="toc-empty">尚无匹配的灵感。</p>'}</div> - </div>`; + </details> + <div class="toc-scroll inspiration-card-list" aria-live="polite">${rows || '<p class="inspiration-empty">尚无匹配的灵感。</p>'}</div> + `); } if (face.type === "inspiration-flow") { const candidate = currentCandidate; - const candidateBody = candidate - ? `<article class="rule-form inspiration-flow-candidate"> - <div class="toc-section">本次浮现 · ${esc(candidate.inspiration.project ?? "未分项目")} · v${esc(candidate.inspiration.version)}</div> - <p>${esc(candidate.inspiration.content)}</p> - <div class="form-hint">${(candidate.inspiration.tags ?? []).map((tag) => `#${esc(tag)}`).join(" ")}</div> - <div class="form-hint">${(candidate.explanation ?? []).map((reason) => esc(reason)).join(" · ")}</div> - <div class="rule-form-grid"> + const candidateIsActionable = isActionableDelivery(candidate?.delivery); + const outcomeControls = candidateIsActionable + ? `<div class="rule-form-grid"> + <label class="form-hint" for="inspirationSnooze">选择「稍后」时延后多少分钟</label> <input class="form-input" id="inspirationSnooze" type="number" min="1" value="${escA(latestSettings?.defaultSnoozeMinutes ?? 120)}" placeholder="稍后分钟数" /> </div> <div class="form-error" id="inspirationFlowError"></div> - <div class="rule-form-foot"> + <div class="inspiration-outcomes" aria-label="记录这次灵感结果"> <button type="button" data-act="inspiration-outcome-viewed" data-id="${escA(candidate.delivery.id)}">查看</button> - <button type="button" data-act="inspiration-outcome-continued" data-id="${escA(candidate.delivery.id)}">继续编辑</button> + <button class="inspiration-primary" type="button" data-act="inspiration-outcome-continued" data-id="${escA(candidate.delivery.id)}">继续编辑</button> <button type="button" data-act="inspiration-outcome-kept" data-id="${escA(candidate.delivery.id)}">保留</button> <button type="button" data-act="inspiration-outcome-later" data-id="${escA(candidate.delivery.id)}">稍后</button> <button type="button" data-act="inspiration-outcome-archived" data-id="${escA(candidate.delivery.id)}">归档</button> + </div>` + : candidate?.delivery.status === "failed" + ? '<div class="inspiration-outcome-note" role="status">通知投递失败,未记录为已展示。这条失败投递不可操作;重新浮现会创建一条新投递。</div>' + : '<div class="inspiration-outcome-note" role="status">此投递不可记录用户结果。</div>'; + const candidateBody = candidate + ? `<article class="inspiration-card inspiration-flow-candidate" data-status="${escA(candidate.inspiration.status)}"> + <div> + <header class="inspiration-card-head"> + <div class="inspiration-card-meta"> + <span class="inspiration-status">本次浮现</span> + <span class="inspiration-chip">${esc(candidate.inspiration.project ?? "未分项目")}</span> + </div> + <span class="inspiration-version">v${esc(candidate.inspiration.version)}</span> + </header> + <p class="inspiration-card-copy">${esc(candidate.inspiration.content)}</p> + <div class="inspiration-tags">${(candidate.inspiration.tags ?? []).map((tag) => `<span class="inspiration-chip">#${esc(tag)}</span>`).join("")}</div> + <p class="inspiration-explanation">${(candidate.explanation ?? []).map((reason) => esc(reason)).join(" · ")}</p> </div> + ${outcomeControls} </article>` - : '<p class="toc-empty">点「浮现下一条」使用服务端选择器。</p>'; - const deliveryRows = latestDeliveries.map((delivery) => - `<div class="toc-row"> - <span class="toc-name">${esc(delivery.status)} · ${esc(delivery.outcome ?? "未处理")}</span> - <span class="toc-dots"></span> - <span class="toc-time">${esc(new Date(delivery.surfacedAt).toLocaleString("zh-CN"))}</span> - </div>` - ).join(""); + : '<p class="inspiration-empty">点「浮现下一条」,由服务端选择一条适合回看的灵感。</p>'; + const deliveryRows = latestDeliveries.map((delivery) => { + return `<div class="inspiration-history-row" data-status="${escA(delivery.status)}"> + <div> + <strong>${esc(deliveryStateLabel(delivery))}</strong> + <time datetime="${escA(delivery.surfacedAt)}">${esc(new Date(delivery.surfacedAt).toLocaleString("zh-CN"))}</time> + </div> + </div>`; + }).join(""); const settings = latestSettings; const settingsBody = settings ? `<div class="rule-form-grid"> @@ -193,22 +588,40 @@ export async function activate({ api }) { <input class="form-input" id="inspirationFlowTags" type="text" value="${escA((settings.tags ?? []).join(","))}" placeholder="候选标签(空为不限)" /> <input class="form-input" id="inspirationFlowProjects" type="text" value="${escA((settings.projects ?? []).join(","))}" placeholder="候选项目(空为不限)" /> </div>` - : '<p class="toc-empty">Flow 设置不可用。</p>'; - return `<div class="leaf-inner toc-face inspiration-flow-face"> - <div class="toc-title">灵感 Flow</div> - <div class="rule-form-foot"> - <button class="seal-btn" type="button" data-act="next-inspiration"><span class="s-face">浮</span><span class="s-label">浮现下一条</span></button> - </div> - ${candidateBody} - <div class="toc-section">Flow 设置${settings ? ` · v${esc(settings.version)}` : ""}</div> - <div class="rule-form"> - ${settingsBody} - <div class="form-error" id="inspirationSettingsError"></div> - ${settings ? '<div class="rule-form-foot"><button type="button" data-act="save-inspiration-settings">保存 Flow 设置</button></div>' : ""} + : '<p class="inspiration-empty">Flow 设置不可用。</p>'; + return renderShell("flow", ` + <header class="inspiration-hero"> + <div class="inspiration-hero-copy"> + <span class="inspiration-kicker">INSPIRATION / FLOW</span> + <div class="toc-title">灵感 Flow</div> + </div> + <div class="inspiration-summary" aria-label="Flow 状态"> + <span><b>${esc(latestDeliveries.length)}</b> 近期投递</span> + ${settings ? `<span><b>v${esc(settings.version)}</b> 选择规则</span>` : ""} + </div> + </header> + <div class="inspiration-flow-layout"> + <section class="inspiration-flow-stage" aria-label="当前浮现灵感"> + <div class="inspiration-flow-toolbar"> + <button class="seal-btn" type="button" data-act="next-inspiration"><span class="s-face">浮</span><span class="s-label">浮现下一条</span></button> + </div> + ${candidateBody} + </section> + <aside class="inspiration-history" aria-label="Flow 投递历史"> + <div class="inspiration-history-title"><strong>投递历史</strong><span>新 → 旧</span></div> + <div class="inspiration-history-list" role="log">${deliveryRows || '<p class="toc-empty">尚无 Flow 投递。</p>'}</div> + ${latestDeliveryNextCursor ? '<div class="inspiration-history-foot"><button type="button" data-act="load-more-inspiration-deliveries">加载更多投递</button></div>' : ""} + </aside> </div> - <div class="toc-section">投递历史</div> - <div class="toc-scroll">${deliveryRows || '<p class="toc-empty">尚无 Flow 投递。</p>'}</div> - </div>`; + <details class="inspiration-settings"> + <summary><span>Flow 设置</span><span>${settings ? `v${esc(settings.version)}` : "不可用"}</span></summary> + <div class="rule-form"> + ${settingsBody} + <div class="form-error" id="inspirationSettingsError"></div> + ${settings ? '<div class="inspiration-card-actions"><button class="inspiration-primary" type="button" data-act="save-inspiration-settings">保存 Flow 设置</button></div>' : ""} + </div> + </details> + `); } return null; }, @@ -287,7 +700,38 @@ export async function activate({ api }) { message: currentCandidate ? "浮现了一条灵感" : "暂无符合条件的灵感", }; } - if (action.startsWith("inspiration-outcome-") && currentCandidate?.delivery.id === id) { + if (action === "load-more-inspiration-deliveries" && latestDeliveryNextCursor) { + const page = await api( + `${API_PREFIX}/flow/deliveries?limit=20&cursor=${encodeURIComponent(latestDeliveryNextCursor)}` + ); + const existing = new Set(latestDeliveries.map((delivery) => delivery.id)); + for (const delivery of Array.isArray(page?.deliveries) ? page.deliveries : []) { + if (!existing.has(delivery.id)) { + existing.add(delivery.id); + latestDeliveries.push(delivery); + } + } + latestDeliveryNextCursor = typeof page?.nextCursor === "string" + ? page.nextCursor + : null; + return { handled: true, message: "已加载更多 Flow 投递" }; + } + const candidateIsActionable = isActionableDelivery(currentCandidate?.delivery); + if ( + action.startsWith("inspiration-outcome-") && + currentCandidate?.delivery.id === id && + !candidateIsActionable + ) { + setError($, "inspirationFlowError", new Error( + "只有成功展示且尚未处理的手动投递可以记录用户结果" + )); + return { handled: true, refresh: false }; + } + if ( + action.startsWith("inspiration-outcome-") && + candidateIsActionable && + currentCandidate?.delivery.id === id + ) { const outcome = action.slice("inspiration-outcome-".length); const body = { expectedDeliveryVersion: currentCandidate.delivery.version, @@ -339,9 +783,14 @@ export async function activate({ api }) { return { handled: false }; }, async unmount() { + mounted = false; + lifecycleVersion += 1; + liveRequestVersion += 1; + refreshInFlight = null; latestInspirations = []; latestSettings = null; latestDeliveries = []; + latestDeliveryNextCursor = null; currentCandidate = null; }, }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 0ac9a67..9491081 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,5 +1,6 @@ import { execSync } from "child_process"; import { Command } from "commander"; +import { parseOffsetAwareIso } from "@echolog/plugin-inspiration/http-validation"; import { ApiError, ConnectionError, api, post, patch, del } from "./api.js"; import { runStdioMcpServer } from "../mcp/index.js"; @@ -1254,6 +1255,15 @@ function inspirationMinute(value: string, option: string): number { return hour * 60 + minute; } +function inspirationTimestamp(value: string, option: string): string { + if (!parseOffsetAwareIso(value)) { + throw new CliUsageError( + `${option} 必须是带 Z 或 ±HH:mm 时区偏移的 ISO 8601 时间` + ); + } + return value; +} + function inspirationItems(result: any): any[] { if (Array.isArray(result)) return result; if (Array.isArray(result?.items)) return result.items; @@ -1367,8 +1377,18 @@ withJson( } if (opts.includeArchived) params.set("includeArchived", "true"); params.set("limit", String(inspirationLimit(opts.limit, "--limit"))); - if (opts.createdBefore) params.set("createdBefore", opts.createdBefore); - if (opts.createdAfter) params.set("createdAfter", opts.createdAfter); + if (opts.createdBefore) { + params.set( + "createdBefore", + inspirationTimestamp(opts.createdBefore, "--created-before") + ); + } + if (opts.createdAfter) { + params.set( + "createdAfter", + inspirationTimestamp(opts.createdAfter, "--created-after") + ); + } if (opts.cursor) params.set("cursor", opts.cursor); const result = await api(`${inspirationApiPrefix}/inspirations?${params}`); printSuccess(thisCommand, result, () => printInspirations(result)); @@ -1637,17 +1657,17 @@ withJson( .command("deliveries") .description("查看 Flow 投递 ledger;不包含灵感正文。") .option("--limit <n>", "返回数量,范围 1–100", "20") - .option("--before <iso>", "surfacedAt 游标,ISO 8601 且包含时区") + .option("--cursor <cursor>", "上一页响应的 opaque nextCursor;原样传回服务端") .addHelpText( "after", - `\n示例:\n $ el inspiration flow deliveries --limit 20\n $ el inspiration flow deliveries --before 2026-08-24T12:00:00+08:00 --json\n` + `\n示例:\n $ el inspiration flow deliveries --limit 20\n $ el inspiration flow deliveries --cursor <nextCursor> --json\n` ) ).action( - action(async (thisCommand, opts: { limit: string; before?: string }) => { + action(async (thisCommand, opts: { limit: string; cursor?: string }) => { const params = new URLSearchParams({ limit: String(inspirationLimit(opts.limit, "--limit")), }); - if (opts.before) params.set("before", opts.before); + if (opts.cursor) params.set("cursor", opts.cursor); const result = await api(`${inspirationApiPrefix}/flow/deliveries?${params}`); printSuccess(thisCommand, result, () => { const deliveries = Array.isArray((result as any).deliveries) diff --git a/tests/inspiration-capture.test.ts b/tests/inspiration-capture.test.ts index f89a2b2..e91774f 100644 --- a/tests/inspiration-capture.test.ts +++ b/tests/inspiration-capture.test.ts @@ -202,6 +202,7 @@ test("manifest and migrations define one private standalone plugin schema", () = "003_inspiration_flow_deliveries", "004_inspiration_flow_delivery_attempts", "005_inspiration_flow_notification_channels", + "006_inspiration_flow_dispatching_status", ]); const sql = migrations.map((migration) => migration.sql).join("\n"); assert.match(sql, /CREATE TABLE IF NOT EXISTS inspirations/); @@ -303,6 +304,40 @@ test("list normalizes filters and preserves deterministic history contract", asy }); }); +test("capture date filters require explicit offsets and preserve DST instants", async () => { + const store = new MemoryCaptureStore(); + const handler = route( + createInspirationRoutes(() => store), + "GET", + "/api/plugins/inspiration/inspirations" + ).handler; + + const accepted = await call(handler, { + query: { + createdAfter: "2026-11-01T01:30:00-04:00", + createdBefore: "2026-11-01T01:30:00-05:00", + }, + }); + assert.deepEqual(accepted, { items: [], nextCursor: null }); + assert.equal(store.lastFilter?.after?.toISOString(), "2026-11-01T05:30:00.000Z"); + assert.equal(store.lastFilter?.before?.toISOString(), "2026-11-01T06:30:00.000Z"); + + for (const createdAfter of [ + "2026-11-01T01:30:00", + "2026-11-01T01:30:00+24:00", + ]) { + const rejected = await call(handler, { query: { createdAfter } }); + assert.equal(rejected.statusCode, 400); + assert.match(rejected.body.error, /Z or ±HH:mm offset/); + } + + const unknown = await call(handler, { + query: { createdAfter: "2026-11-01T01:30:00-04:00", timezone: "local" }, + }); + assert.equal(unknown.statusCode, 400); + assert.equal(unknown.body.error, "unknown field: timezone"); +}); + test("opaque cursor preserves timestamp and id tie-break boundary", async () => { const store = new MemoryCaptureStore(); const routes = createInspirationRoutes(() => store); diff --git a/tests/inspiration-clients.test.ts b/tests/inspiration-clients.test.ts index 5334767..ae6e2f0 100644 --- a/tests/inspiration-clients.test.ts +++ b/tests/inspiration-clients.test.ts @@ -165,6 +165,42 @@ test("Inspiration CLI is HTTP-thin and preserves raw JSON success and errors", a body: { idempotencyKey: "manual-test" }, }); + const filtered = await runCli(configPath, [ + "inspiration", + "list", + "--created-after", + "2026-11-01T01:30:00-04:00", + "--json", + ]); + assert.equal(filtered.exitCode, 0); + assert.match( + calls.at(-1)?.url ?? "", + /createdAfter=2026-11-01T01%3A30%3A00-04%3A00/ + ); + + const callCount = calls.length; + const timezoneLess = await runCli(configPath, [ + "inspiration", + "list", + "--created-before", + "2026-11-01T01:30:00", + "--json", + ]); + assert.equal(timezoneLess.exitCode, 1); + assert.equal(calls.length, callCount); + assert.match(JSON.parse(timezoneLess.stderr).error, /Z.*HH:mm/); + + const history = await runCli(configPath, [ + "inspiration", + "flow", + "deliveries", + "--cursor", + "opaque_page_2", + "--json", + ]); + assert.equal(history.exitCode, 0); + assert.match(calls.at(-1)?.url ?? "", /cursor=opaque_page_2/); + const failed = await runCli(configPath, [ "inspiration", "show", @@ -190,6 +226,14 @@ test("Inspiration CLI is HTTP-thin and preserves raw JSON success and errors", a assert.match(help.stdout, /viewed \| continued \| kept \| later \| archived/); assert.match(help.stdout, /--delivery-version/); assert.match(help.stdout, /--inspiration-version/); + const deliveriesHelp = await runCli(configPath, [ + "inspiration", + "flow", + "deliveries", + "--help", + ]); + assert.match(deliveriesHelp.stdout, /--cursor/); + assert.doesNotMatch(deliveriesHelp.stdout, /--before/); } finally { await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve() @@ -230,6 +274,180 @@ test("Inspiration Web contributes only while ready", async () => { await host.stop(); }); +test("Inspiration Web invalidates changed live snapshots without polling rebuilds", async () => { + const { activate } = await import(webModulePath); + let inspirationVersion = 1; + let deliveryStatus = "sent"; + let refreshes = 0; + let activeElement: null | { tagName: string; closest(selector: string): object | null } = null; + const root = { + ownerDocument: { + get activeElement() { + return activeElement; + }, + }, + }; + const api = async (path: string) => { + if (path.includes("/inspirations?")) { + return { + items: [{ + id: "inspiration-live", + version: inspirationVersion, + content: `live idea v${inspirationVersion}`, + tags: [], + project: null, + status: "inbox", + }], + nextCursor: null, + }; + } + if (path.endsWith("/flow/settings")) { + return { id: "default", version: 1, defaultSnoozeMinutes: 120 }; + } + if (path.includes("/flow/deliveries?")) { + return { + deliveries: [{ + id: "delivery-live", + version: 1, + source: "scheduled", + status: deliveryStatus, + outcome: null, + surfacedAt: "2026-08-26T08:00:00.000Z", + }], + nextCursor: "opaque-live-cursor", + }; + } + throw new Error(`Unexpected API path: ${path}`); + }; + const contribution = await activate({ + api, + root, + refresh: async () => { + refreshes += 1; + }, + }); + + await contribution.load(); + await contribution.loadLive(); + assert.equal(refreshes, 0); + + inspirationVersion = 2; + await contribution.loadLive(); + assert.equal(refreshes, 1); + await contribution.loadLive(); + assert.equal(refreshes, 1); + + deliveryStatus = "failed"; + await contribution.loadLive(); + assert.equal(refreshes, 2); + await contribution.loadLive(); + assert.equal(refreshes, 2); + + activeElement = { + tagName: "TEXTAREA", + closest: (selector) => selector === "#pages" ? {} : null, + }; + inspirationVersion = 3; + await contribution.loadLive(); + assert.equal(refreshes, 2); + + activeElement = null; + await contribution.loadLive(); + assert.equal(refreshes, 3); + await contribution.unmount(); +}); + +test("Inspiration Web ignores in-flight live snapshots after unmount", async () => { + const { activate } = await import(webModulePath); + let releaseList: ((value: unknown) => void) | undefined; + let releaseLedger: ((value: unknown) => void) | undefined; + let apiCalls = 0; + let refreshes = 0; + const api = (path: string) => { + apiCalls += 1; + if (path.includes("/inspirations?")) { + return new Promise((resolve) => { + releaseList = resolve; + }); + } + if (path.includes("/flow/deliveries?")) { + return new Promise((resolve) => { + releaseLedger = resolve; + }); + } + throw new Error(`Unexpected API path: ${path}`); + }; + const contribution = await activate({ + api, + root: { ownerDocument: { activeElement: null } }, + refresh: async () => { + refreshes += 1; + }, + }); + + const pending = contribution.loadLive(); + await contribution.unmount(); + releaseList?.({ items: [{ id: "late", version: 1 }], nextCursor: null }); + releaseLedger?.({ deliveries: [{ id: "late-delivery" }], nextCursor: null }); + assert.deepEqual(await pending, {}); + assert.equal(refreshes, 0); + + const callsAfterUnmount = apiCalls; + assert.deepEqual(await contribution.loadLive(), {}); + assert.equal(apiCalls, callsAfterUnmount); + assert.equal(refreshes, 0); +}); + +test("Inspiration Web coalesces overlapping live polls behind one Host refresh", async () => { + const { activate } = await import(webModulePath); + let inspirationVersion = 1; + let refreshes = 0; + let releaseRefresh: (() => void) | undefined; + let signalRefreshStarted: (() => void) | undefined; + const refreshStarted = new Promise<void>((resolve) => { + signalRefreshStarted = resolve; + }); + const api = async (path: string) => { + if (path.includes("/inspirations?")) { + return { + items: [{ id: "overlap", version: inspirationVersion }], + nextCursor: null, + }; + } + if (path.endsWith("/flow/settings")) return { id: "default", version: 1 }; + if (path.includes("/flow/deliveries?")) { + return { deliveries: [], nextCursor: null }; + } + throw new Error(`Unexpected API path: ${path}`); + }; + const contribution = await activate({ + api, + root: { ownerDocument: { activeElement: null } }, + refresh: async () => { + refreshes += 1; + signalRefreshStarted?.(); + await new Promise<void>((resolve) => { + releaseRefresh = resolve; + }); + }, + }); + + await contribution.load(); + inspirationVersion = 2; + const firstPoll = contribution.loadLive(); + await refreshStarted; + const overlappingPoll = contribution.loadLive(); + await Promise.resolve(); + assert.equal(refreshes, 1); + + releaseRefresh?.(); + await Promise.all([firstPoll, overlappingPoll]); + assert.equal(refreshes, 1); + await contribution.loadLive(); + assert.equal(refreshes, 1); + await contribution.unmount(); +}); + test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow policy", async () => { const { activate } = await import(webModulePath); const malicious = '<img src=x onerror="alert(1)">'; @@ -267,6 +485,14 @@ test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow poli createdAt: "2026-08-24T05:00:00.000Z", updatedAt: "2026-08-24T05:00:00.000Z", }; + const scheduledFailure = { + ...delivery, + id: "delivery-2", + source: "scheduled", + status: "failed", + dedupeKey: "scheduled:2:180:123", + error: "notifications.send failed", + }; const settings = { id: "default", version: 2, @@ -289,7 +515,11 @@ test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow poli return { items: [inspiration], nextCursor: null }; } if (path.endsWith("/flow/settings") && !options) return settings; - if (path.includes("/flow/deliveries?") && !options) return { deliveries: [delivery] }; + if (path.includes("/flow/deliveries?") && !options) { + return path.includes("cursor=opaque_page_2") + ? { deliveries: [scheduledFailure], nextCursor: null } + : { deliveries: [delivery], nextCursor: "opaque_page_2" }; + } if (path.endsWith("/flow/next")) { return { candidate: { @@ -324,6 +554,13 @@ test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow poli assert.equal(inboxHtml.includes("<script>alert(2)</script>"), false); assert.match(inboxHtml, /<img src=x onerror="alert\(1\)">/); assert.match(inboxHtml, /<script>alert\(2\)<\/script>/); + assert.match(inboxHtml, /inspiration-shell inspiration-inbox-face/); + assert.match(inboxHtml, /inspiration-composer/); + assert.match(inboxHtml, /inspiration-card" data-status="inbox"/); + assert.match(inboxHtml, /<details class="inspiration-filter-panel"/); + assert.match(inboxHtml, /aria-label="新灵感正文"/); + assert.match(inboxHtml, /@container \(max-width: 34rem\)/); + assert.match(inboxHtml, /prefers-reduced-motion/); const elements: Record<string, { value?: string; checked?: boolean; textContent?: string }> = { inspirationNewContent: { value: "new idea" }, @@ -428,6 +665,7 @@ test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow poli }, }); + delivery.status = "failed"; await contribution.handleAction("next-inspiration", { id: undefined, $ }); const flowHtml = contribution.renderFace( { type: "inspiration-flow" }, @@ -435,8 +673,33 @@ test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow poli ); assert.equal(flowHtml.includes(malicious), false); assert.match(flowHtml, /<img src=x onerror="alert\(1\)">/); + assert.match(flowHtml, /inspiration-flow-layout/); + assert.match(flowHtml, /inspiration-flow-candidate/); + assert.match(flowHtml, /inspiration-history-list" role="log"/); + assert.match(flowHtml, /手动投递失败(未展示)/); + assert.match(flowHtml, /通知投递失败,未记录为已展示/); + assert.match(flowHtml, /这条失败投递不可操作/); + assert.doesNotMatch(flowHtml, /inspiration-outcome-later/); elements.inspirationSnooze = { value: "90" }; elements.inspirationFlowError = { textContent: "" }; + const callsBeforeRejectedOutcome = calls.length; + const rejectedOutcome = await contribution.handleAction("inspiration-outcome-later", { + id: delivery.id, + $, + }); + assert.deepEqual(rejectedOutcome, { handled: true, refresh: false }); + assert.equal(calls.length, callsBeforeRejectedOutcome); + assert.match(elements.inspirationFlowError.textContent ?? "", /只有成功展示/); + + delivery.status = "sent"; + await contribution.handleAction("next-inspiration", { id: undefined, $ }); + const actionableFlowHtml = contribution.renderFace( + { type: "inspiration-flow" }, + { data, esc: escapeText, escA: escapeAttribute } + ); + assert.match(actionableFlowHtml, /inspiration-outcome-later/); + assert.match(actionableFlowHtml, /inspiration-outcomes/); + assert.match(actionableFlowHtml, /class="inspiration-primary"[^>]+inspiration-outcome-continued/); const outcomeResult = await contribution.handleAction("inspiration-outcome-later", { id: delivery.id, $, @@ -454,6 +717,21 @@ test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow poli }), }, }); + const more = await contribution.handleAction( + "load-more-inspiration-deliveries", + { id: undefined, $ } + ); + assert.equal(more.handled, true); + assert.equal( + calls.at(-1)?.path, + "/plugins/inspiration/flow/deliveries?limit=20&cursor=opaque_page_2" + ); + const paginatedHtml = contribution.renderFace( + { type: "inspiration-flow" }, + { data, esc: escapeText, escA: escapeAttribute } + ); + assert.match(paginatedHtml, /定时投递失败(未展示)/); + assert.doesNotMatch(paginatedHtml, /load-more-inspiration-deliveries/); assert.equal(calls.every((call) => call.path.startsWith("/plugins/inspiration")), true); }); diff --git a/tests/inspiration-flow.test.ts b/tests/inspiration-flow.test.ts index 3c21a57..afab2fd 100644 --- a/tests/inspiration-flow.test.ts +++ b/tests/inspiration-flow.test.ts @@ -5,7 +5,12 @@ import type { PluginNotificationResult, } from "@echolog/plugin-sdk"; import { createFlowRoutes, validateOutcome, validateSettingsUpdate } from "../plugins/inspiration/src/flow-routes.js"; -import { FlowStoreError, type FlowOutcomeResult, type FlowReserveResult } from "../plugins/inspiration/src/flow-store.js"; +import { + canApplyFlowOutcome, + FlowStoreError, + type FlowOutcomeResult, + type FlowReserveResult, +} from "../plugins/inspiration/src/flow-store.js"; import { createFlowJob, FlowService, @@ -18,6 +23,7 @@ import { selectFlowCandidate, type SelectableInspiration, } from "../plugins/inspiration/src/selector.js"; +import { encodeDeliveryCursor } from "../plugins/inspiration/src/pagination.js"; import type { FlowCandidate, FlowDelivery, @@ -132,23 +138,31 @@ function persistence( async reserveNext() { return reserveResult(); }, + async claimNotification(_id, _version, at, signal) { + signal?.throwIfAborted(); + return delivery({ + version: 2, + status: "dispatching", + updatedAt: at ?? NOW, + }); + }, async finalizeNotification(_id, _version, result) { return result.delivered ? delivery({ - version: 2, + version: 3, status: "sent", notifiedAt: result.at, notificationChannels: result.channels, }) : delivery({ - version: 2, + version: 3, status: "failed", notificationChannels: result.channels, error: result.error, }); }, async listDeliveries() { - return []; + return { deliveries: [], nextCursor: null }; }, async applyOutcome() { return outcomeResult(); @@ -275,16 +289,41 @@ test("selector explains lifecycle, filter, snooze, and cooldown exclusions", () ]); }); -test("scheduled bucket keys are stable across repeated polls and vary by interval", () => { +test("scheduled bucket keys include the locked settings version and interval", () => { const withinBucket = new Date(NOW.getTime() + 30_000); assert.equal( - scheduledFlowDedupeKey(NOW, 60), - scheduledFlowDedupeKey(withinBucket, 60) + scheduledFlowDedupeKey(NOW, 4, 60), + scheduledFlowDedupeKey(withinBucket, 4, 60) ); assert.notEqual( - scheduledFlowDedupeKey(NOW, 60), - scheduledFlowDedupeKey(NOW, 30) + scheduledFlowDedupeKey(NOW, 4, 60), + scheduledFlowDedupeKey(NOW, 5, 60) ); + assert.notEqual( + scheduledFlowDedupeKey(NOW, 5, 60), + scheduledFlowDedupeKey(NOW, 5, 30) + ); +}); + +test("scheduled service delegates key generation to the locked Store snapshot", async () => { + const calls: unknown[][] = []; + let settingsReads = 0; + const service = new FlowService(persistence({ + async getSettings() { + settingsReads += 1; + return settings(); + }, + async reserveNext(...args) { + calls.push(args); + return { candidate: null, explanation: [], shouldNotify: false }; + }, + }), () => async () => assert.fail("no candidate should not notify"), () => NOW); + + const controller = new AbortController(); + await service.runScheduled(controller.signal); + + assert.equal(settingsReads, 0); + assert.deepEqual(calls, [["scheduled", undefined, NOW, controller.signal]]); }); test("scheduled job is bounded and forwards the Host abort signal", async () => { @@ -304,7 +343,7 @@ test("scheduled job is bounded and forwards the Host abort signal", async () => assert.deepEqual(observed, [controller.signal]); }); -test("service calls the function-valued notification contract with title and message only", async () => { +test("service calls notifications.send with text and the stable delivery dedupe key", async () => { const finalized: unknown[] = []; const sent: Array<{ input: unknown; signal: AbortSignal | undefined }> = []; const controller = new AbortController(); @@ -333,14 +372,15 @@ test("service calls the function-valued notification contract with title and mes input: { title: "Inspiration", message: "Build a deterministic inspiration flow", + dedupeKey: "inspiration:manual:request-a", }, signal: controller.signal, }]); assert.equal(finalized.length, 1); - assert.deepEqual((finalized[0] as unknown[]).slice(0, 2), ["delivery-a", 1]); + assert.deepEqual((finalized[0] as unknown[]).slice(0, 2), ["delivery-a", 2]); }); -test("reserved duplicate resumes after restart but sent duplicate is not re-sent", async () => { +test("sent duplicate is not re-sent after the pre-send claim", async () => { let sends = 0; let state: "reserved" | "sent" = "reserved"; const store = persistence({ @@ -370,6 +410,179 @@ test("reserved duplicate resumes after restart but sent duplicate is not re-sent assert.equal(sends, 1); }); +test("an interrupted dispatch is terminalized without another notification", async () => { + let state: FlowDelivery["status"] = "reserved"; + let version = 1; + let reserveCalls = 0; + let sends = 0; + const controller = new AbortController(); + const store = persistence({ + async reserveNext() { + reserveCalls += 1; + if (state === "dispatching") { + state = "failed"; + version += 1; + } + const value = candidate({ + duplicate: reserveCalls > 1, + delivery: delivery({ status: state, version }), + }); + return { + candidate: value, + explanation: state === "failed" + ? ["recovery:interrupted-dispatch-unknown"] + : value.explanation, + shouldNotify: state === "reserved", + }; + }, + async claimNotification() { + assert.equal(state, "reserved"); + state = "dispatching"; + version += 1; + return delivery({ status: state, version }); + }, + }); + const service = new FlowService(store, () => async () => { + sends += 1; + controller.abort(); + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, () => NOW); + + await assert.rejects( + service.nextManual("same-request", controller.signal), + (error) => error instanceof Error && error.name === "AbortError" + ); + const recovered = await service.nextManual("same-request"); + + assert.equal(sends, 1); + assert.equal(recovered.shouldNotify, false); + assert.equal(recovered.candidate?.delivery.status, "failed"); + assert.deepEqual(recovered.explanation, [ + "recovery:interrupted-dispatch-unknown", + "delivery:failed", + ]); +}); + +test("an explicitly failed delivery can retry only as a distinct later bucket", async () => { + let sends = 0; + let nextDelivery = 0; + const ids: string[] = []; + const notificationKeys: Array<string | undefined> = []; + let activeDedupeKey = ""; + const store = persistence({ + async reserveNext() { + nextDelivery += 1; + const id = `delivery-${nextDelivery}`; + activeDedupeKey = `manual:bucket-${nextDelivery}`; + ids.push(id); + return reserveResult(candidate({ + delivery: delivery({ id, dedupeKey: activeDedupeKey }), + })); + }, + async claimNotification(id) { + return delivery({ + id, + version: 2, + status: "dispatching", + dedupeKey: activeDedupeKey, + }); + }, + async finalizeNotification(id, _version, result) { + return delivery({ + id, + version: 3, + status: result.delivered ? "sent" : "failed", + error: result.delivered ? null : result.error, + }); + }, + }); + const service = new FlowService(store, () => async (request) => { + sends += 1; + notificationKeys.push(request.dedupeKey); + if (sends === 1) throw new Error("explicit transport failure"); + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, () => NOW); + + const failed = await service.nextManual("bucket-1"); + const retried = await service.nextManual("bucket-2"); + + assert.equal(failed.candidate?.delivery.status, "failed"); + assert.equal(retried.candidate?.delivery.status, "sent"); + assert.equal(sends, 2); + assert.deepEqual(ids, ["delivery-1", "delivery-2"]); + assert.deepEqual(notificationKeys, [ + "inspiration:manual:bucket-1", + "inspiration:manual:bucket-2", + ]); +}); + +test("failed deliveries are terminal and non-actionable for every source", () => { + assert.equal(canApplyFlowOutcome({ status: "sent", source: "scheduled" }), true); + assert.equal(canApplyFlowOutcome({ status: "failed", source: "manual" }), false); + assert.equal(canApplyFlowOutcome({ status: "failed", source: "scheduled" }), false); + assert.equal(canApplyFlowOutcome({ status: "dispatching", source: "manual" }), false); +}); + +test("a failed delivery is diagnostic only and the same key never sends twice", async () => { + let sends = 0; + let state: FlowDelivery["status"] = "reserved"; + let version = 1; + const store = persistence({ + async reserveNext() { + const value = candidate({ + duplicate: state === "failed", + delivery: delivery({ + version, + status: state, + error: state === "failed" ? "notifications.send failed" : null, + }), + }); + return reserveResult(value); + }, + async claimNotification() { + state = "dispatching"; + version += 1; + return delivery({ version, status: state }); + }, + async finalizeNotification(_id, _version, result) { + state = "failed"; + version += 1; + return delivery({ + version, + status: state, + error: result.delivered ? null : result.error, + }); + }, + }); + const service = new FlowService(store, () => async () => { + sends += 1; + throw new Error("provider failed"); + }, () => NOW); + + const first = await service.nextManual("same-failed-key"); + const duplicate = await service.nextManual("same-failed-key"); + + assert.equal(sends, 1); + assert.equal(first.candidate?.delivery.status, "failed"); + assert.equal(duplicate.candidate?.delivery.status, "failed"); + assert.equal(duplicate.shouldNotify, false); + assert.equal(duplicate.candidate?.delivery.error, "notifications.send failed"); + assert.deepEqual(duplicate.explanation, [ + "selection:never-surfaced-first", + "delivery:failed", + ]); +}); + test("notification failures are recorded without leaking provider error text", async () => { let finalization: unknown; const service = new FlowService(persistence({ @@ -383,6 +596,7 @@ test("notification failures are recorded without leaking provider error text", a const result = await service.nextManual("failed-request"); assert.equal(result.candidate?.delivery.status, "failed"); + assert.equal(result.explanation.includes("delivery:failed"), true); assert.deepEqual(finalization, { delivered: false, channels: null, @@ -495,7 +709,7 @@ test("notification ledger projects only bounded official channel fields", async assert.equal(JSON.stringify(finalization).includes("must-not-be-persisted"), false); }); -test("abort leaves a durable reservation for a later restart", async () => { +test("abort before the pre-send claim never calls notifier or finalizer", async () => { let finalized = false; const controller = new AbortController(); controller.abort(); @@ -625,11 +839,15 @@ test("delivery API DTO retains the projected channel ledger", async () => { status: "sent", notificationChannels: projected, }); + const cursor = { + surfacedAt: new Date("2026-08-24T13:00:00.000Z"), + id: "delivery-z", + }; const service = { - async listDeliveries(limit: number, before?: Date) { + async listDeliveries(limit: number, receivedCursor?: typeof cursor) { assert.equal(limit, 10); - assert.equal(before?.toISOString(), "2026-08-24T13:00:00.000Z"); - return [stored]; + assert.deepEqual(receivedCursor, cursor); + return { deliveries: [stored], nextCursor: null }; }, } as unknown as FlowService; const route = createFlowRoutes(() => service).find( @@ -637,12 +855,12 @@ test("delivery API DTO retains the projected channel ledger", async () => { )!; const result = await route.handler({ params: {}, - query: { limit: "10", before: "2026-08-24T13:00:00.000Z" }, + query: { limit: "10", cursor: encodeDeliveryCursor(cursor) }, body: null, headers: {}, }, new AbortController().signal); - assert.deepEqual(result, { deliveries: [stored] }); + assert.deepEqual(result, { deliveries: [stored], nextCursor: null }); assert.deepEqual( (result as { deliveries: FlowDelivery[] }).deliveries[0]?.notificationChannels, projected diff --git a/tests/inspiration-http.test.ts b/tests/inspiration-http.test.ts new file mode 100644 index 0000000..f2b1b11 --- /dev/null +++ b/tests/inspiration-http.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { PluginHttpRequest, PluginRoute } from "@echolog/plugin-sdk"; +import type { FlowService } from "../plugins/inspiration/src/flow.js"; +import { createFlowRoutes } from "../plugins/inspiration/src/flow-routes.js"; +import { parseOffsetAwareIso } from "../plugins/inspiration/src/http-validation.js"; +import { + decodeDeliveryCursor, + encodeDeliveryCursor, + type DeliveryCursor, +} from "../plugins/inspiration/src/pagination.js"; + +function deliveryRoute(): PluginRoute { + const found = createFlowRoutes(() => service).find( + (candidate) => + candidate.method === "GET" && + candidate.path === "/api/plugins/inspiration/flow/deliveries" + ); + assert.ok(found); + return found; +} + +let receivedCursor: DeliveryCursor | undefined; +const service = { + async listDeliveries(_limit: number, cursor?: DeliveryCursor) { + receivedCursor = cursor; + return { + deliveries: [{ id: "delivery_same_b", surfacedAt: cursor?.surfacedAt }], + nextCursor: { + surfacedAt: new Date("2026-08-24T05:00:00.000Z"), + id: "delivery_same_a", + }, + }; + }, +} as unknown as FlowService; + +async function call(query: unknown): Promise<any> { + return deliveryRoute().handler({ + params: {}, + query, + body: undefined, + headers: {}, + } as PluginHttpRequest, new AbortController().signal); +} + +test("offset-aware ISO validation rejects local and malformed timestamps", () => { + assert.equal( + parseOffsetAwareIso("2026-11-01T01:30:00-04:00")?.toISOString(), + "2026-11-01T05:30:00.000Z" + ); + assert.equal( + parseOffsetAwareIso("2026-11-01T01:30:00-05:00")?.toISOString(), + "2026-11-01T06:30:00.000Z" + ); + assert.equal(parseOffsetAwareIso("2026-11-01T01:30:00"), null); + assert.equal(parseOffsetAwareIso("2026-11-01T01:30:00+24:00"), null); + assert.equal(parseOffsetAwareIso("2026-02-30T01:30:00Z"), null); + assert.equal(parseOffsetAwareIso("0000-01-01T00:00:00Z"), null); + assert.equal( + parseOffsetAwareIso("2000-02-29T00:00:00Z")?.toISOString(), + "2000-02-29T00:00:00.000Z" + ); + assert.equal(parseOffsetAwareIso("1900-02-29T00:00:00Z"), null); +}); + +test("delivery cursor is opaque, composite, and offset-aware", () => { + const surfacedAt = new Date("2026-08-24T05:00:00.000Z"); + const encoded = encodeDeliveryCursor({ surfacedAt, id: "delivery_same_b" }); + assert.equal(encoded.includes("2026-08-24"), false); + assert.deepEqual(decodeDeliveryCursor(encoded), { + surfacedAt, + id: "delivery_same_b", + }); + + const localTime = Buffer.from(JSON.stringify({ + surfacedAt: "2026-08-24T05:00:00", + id: "delivery_same_b", + })).toString("base64url"); + assert.equal(decodeDeliveryCursor(localTime), null); + + const unknownField = Buffer.from(JSON.stringify({ + surfacedAt: "2026-08-24T05:00:00Z", + id: "delivery_same_b", + before: "legacy", + })).toString("base64url"); + assert.equal(decodeDeliveryCursor(unknownField), null); +}); + +test("delivery API passes the composite cursor and returns only an opaque next cursor", async () => { + const cursor = encodeDeliveryCursor({ + surfacedAt: new Date("2026-08-24T05:00:00.000Z"), + id: "delivery_same_b", + }); + const result = await call({ limit: "1", cursor }); + assert.equal(receivedCursor?.surfacedAt.toISOString(), "2026-08-24T05:00:00.000Z"); + assert.equal(receivedCursor?.id, "delivery_same_b"); + assert.equal(result.deliveries[0].id, "delivery_same_b"); + assert.deepEqual(decodeDeliveryCursor(result.nextCursor), { + surfacedAt: new Date("2026-08-24T05:00:00.000Z"), + id: "delivery_same_a", + }); + + for (const query of [ + { before: "2026-08-24T05:00:00Z" }, + { cursor: [cursor, cursor] }, + { cursor: "not-a-cursor" }, + ]) { + const rejected = await call(query); + assert.equal(rejected.statusCode, 400); + } +}); diff --git a/tests/inspiration-notification-host.test.ts b/tests/inspiration-notification-host.test.ts index 34e6a07..325e72e 100644 --- a/tests/inspiration-notification-host.test.ts +++ b/tests/inspiration-notification-host.test.ts @@ -132,10 +132,13 @@ function persistence( shouldNotify: true, }; }, + async claimNotification() { + return delivery({ version: 2, status: "dispatching" }); + }, async finalizeNotification(_id, _version, result) { onFinalize(result); return delivery({ - version: 2, + version: 3, status: result.delivered ? "sent" : "failed", notifiedAt: result.delivered ? result.at : null, notificationChannels: result.channels, @@ -143,7 +146,7 @@ function persistence( }); }, async listDeliveries() { - return []; + return { deliveries: [], nextCursor: null }; }, async applyOutcome() { return { delivery: delivery(), inspiration: inspiration() }; @@ -198,7 +201,7 @@ test("real PluginHost denies the actual Inspiration provider without permission" assert.equal(serviceCalls, 0); }); -test("actual Inspiration provider passes a bare Host function only title/message", async () => { +test("actual Inspiration provider passes a bare Host function with a stable dedupe key", async () => { const requests: unknown[] = []; const finalizations: FlowNotificationFinalization[] = []; const send: PluginNotificationSend = async (request) => { @@ -230,6 +233,7 @@ test("actual Inspiration provider passes a bare Host function only title/message assert.deepEqual(requests, [{ title: "Inspiration", message: "A Host-integrated inspiration", + dedupeKey: "inspiration:manual:host", }]); assert.deepEqual(finalizations, [{ delivered: true, diff --git a/tests/inspiration.integration.ts b/tests/inspiration.integration.ts index 1e3e980..69b5102 100644 --- a/tests/inspiration.integration.ts +++ b/tests/inspiration.integration.ts @@ -2,12 +2,24 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test from "node:test"; import postgres from "postgres"; -import { FlowStore } from "../plugins/inspiration/src/flow-store.js"; +import { + FLOW_RESERVATION_LEASE_MS, + FLOW_UNKNOWN_DISPATCH_ERROR, + FlowStore, + FlowStoreError, +} from "../plugins/inspiration/src/flow-store.js"; +import { FlowService } from "../plugins/inspiration/src/flow.js"; import { migrations } from "../plugins/inspiration/src/migrations.js"; +import { + decodeDeliveryCursor, + encodeDeliveryCursor, + type DeliveryCursor, +} from "../plugins/inspiration/src/pagination.js"; import { InspirationStore, InspirationStoreError, } from "../plugins/inspiration/src/store.js"; +import type { FlowSettingsUpdate } from "../plugins/inspiration/src/types.js"; import { createPluginMigrationRunner } from "../src/core/plugins/migrations.js"; const testDatabaseUrl = process.env.ECHOLOG_TEST_DATABASE_URL; @@ -25,12 +37,88 @@ function quoteTestSchema(schema: string): string { return `"${schema}"`; } -function databaseUrlForSchema(databaseUrl: string, schema: string): string { +function databaseUrlForSchema( + databaseUrl: string, + schema: string, + applicationName?: string +): string { const url = new URL(databaseUrl); url.searchParams.set("options", `-c search_path=${schema}`); + if (applicationName) url.searchParams.set("application_name", applicationName); return url.toString(); } +interface IntegrationFixture { + admin: ReturnType<typeof postgres>; + databaseUrl: string; +} + +async function withIntegrationSchema( + run: (fixture: IntegrationFixture) => Promise<void> +): Promise<void> { + if (!testDatabaseUrl) return; + const schema = testSchemaName(); + const quotedSchema = quoteTestSchema(schema); + const scopedDatabaseUrl = databaseUrlForSchema(testDatabaseUrl, schema); + const admin = postgres(testDatabaseUrl, { max: 1 }); + let schemaCreated = false; + try { + await admin.unsafe(`CREATE SCHEMA ${quotedSchema}`); + schemaCreated = true; + const migrationRunner = createPluginMigrationRunner(scopedDatabaseUrl); + await migrationRunner("inspiration", migrations); + await migrationRunner("inspiration", migrations); + await run({ admin, databaseUrl: scopedDatabaseUrl }); + } finally { + if (schemaCreated) await admin.unsafe(`DROP SCHEMA ${quotedSchema} CASCADE`); + await admin.end(); + } +} + +async function configureFlow( + store: FlowStore, + overrides: Partial<Omit<FlowSettingsUpdate, "expectedVersion">> = {} +) { + const current = await store.getSettings(); + const input: FlowSettingsUpdate = { + expectedVersion: current.version, + enabled: true, + intervalMinutes: 60, + quietStartMinute: 0, + quietEndMinute: 0, + cooldownMinutes: 0, + dailyLimit: 100, + defaultSnoozeMinutes: 120, + statuses: ["inbox", "kept"], + tags: [], + projects: [], + ...overrides, + }; + const updated = await store.updateSettings(input); + assert.ok(updated); + assert.equal(updated.version, current.version + 1); + return updated; +} + +async function waitForDatabaseLock( + admin: ReturnType<typeof postgres>, + applicationName: string +): Promise<void> { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const rows = await admin<{ waiting: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE application_name = ${applicationName} + AND wait_event_type = 'Lock' + ) AS waiting + `; + if (rows[0]?.waiting) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`database client ${applicationName} never entered a lock wait`); +} + test("inspiration integration requires an explicit test database URL", () => { assert.ok( testDatabaseUrl, @@ -39,34 +127,14 @@ test("inspiration integration requires an explicit test database URL", () => { }); test( - "real PostgreSQL enforces optimistic writes, atomic dedupe, snooze isolation, and cross-bucket recovery", + "real PostgreSQL preserves optimistic writes, atomic dedupe, and snooze isolation", { skip: !testDatabaseUrl, timeout: 30_000 }, - async () => { - if (!testDatabaseUrl) return; - - const schema = testSchemaName(); - const quotedSchema = quoteTestSchema(schema); - const scopedDatabaseUrl = databaseUrlForSchema(testDatabaseUrl, schema); - const admin = postgres(testDatabaseUrl, { max: 1 }); - const blocker = postgres(scopedDatabaseUrl, { max: 1 }); - const captureStores: InspirationStore[] = []; - const flowStores: FlowStore[] = []; - let schemaCreated = false; - + async () => withIntegrationSchema(async ({ databaseUrl }) => { + const captureA = new InspirationStore(databaseUrl); + const captureB = new InspirationStore(databaseUrl); + const flowA = new FlowStore(databaseUrl); + const flowB = new FlowStore(databaseUrl); try { - await admin.unsafe(`CREATE SCHEMA ${quotedSchema}`); - schemaCreated = true; - const migrationRunner = createPluginMigrationRunner(scopedDatabaseUrl); - await migrationRunner("inspiration", migrations); - await migrationRunner("inspiration", migrations); - - const captureA = new InspirationStore(scopedDatabaseUrl); - const captureB = new InspirationStore(scopedDatabaseUrl); - const flowA = new FlowStore(scopedDatabaseUrl); - const flowB = new FlowStore(scopedDatabaseUrl); - captureStores.push(captureA, captureB); - flowStores.push(flowA, flowB); - const first = await captureA.create({ content: "first durable idea", tags: ["flow"], @@ -95,26 +163,12 @@ test( project: "EchoLog", status: "inbox", }); - const initialSettings = await flowA.getSettings(); - const configured = await flowA.updateSettings({ - expectedVersion: initialSettings.version, - enabled: true, - intervalMinutes: 60, - quietStartMinute: 0, - quietEndMinute: 0, - cooldownMinutes: 0, - dailyLimit: 100, - defaultSnoozeMinutes: 120, - statuses: ["inbox", "kept"], - tags: [], - projects: [], - }); - assert.equal(configured?.version, initialSettings.version + 1); + await configureFlow(flowA); - const manualNow = new Date("2026-08-24T08:00:00.000Z"); + const now = new Date("2026-08-24T08:00:00.000Z"); const reservations = await Promise.all([ - flowA.reserveNext("manual", "manual:postgres-race", manualNow), - flowB.reserveNext("manual", "manual:postgres-race", manualNow), + flowA.reserveNext("manual", "manual:postgres-race", now), + flowB.reserveNext("manual", "manual:postgres-race", now), ]); assert.equal(reservations.filter((result) => result.shouldNotify).length, 1); assert.equal( @@ -123,25 +177,27 @@ test( ); const owner = reservations.find((result) => result.shouldNotify); assert.ok(owner?.candidate); - const sent = await flowA.finalizeNotification( + const dispatching = await flowA.claimNotification( owner.candidate.delivery.id, owner.candidate.delivery.version, + now + ); + assert.equal(dispatching.status, "dispatching"); + const sent = await flowA.finalizeNotification( + dispatching.id, + dispatching.version, { delivered: true, channels: { mac: { status: "sent" }, ntfy: { status: "disabled" }, }, - at: manualNow, + at: now, } ); assert.equal(sent.status, "sent"); assert.equal(sent.attempts, 1); - assert.deepEqual(sent.notificationChannels, { - mac: { status: "sent" }, - ntfy: { status: "disabled" }, - }); - const sentFromLedger = (await flowB.listDeliveries()).find( + const sentFromLedger = (await flowB.listDeliveries()).deliveries.find( (item) => item.id === sent.id ); assert.deepEqual(sentFromLedger?.notificationChannels, { @@ -155,8 +211,8 @@ test( sent.version, owner.candidate.inspiration.version, "later", - new Date(manualNow.getTime() + 120 * 60_000), - manualNow + new Date(now.getTime() + 120 * 60_000), + now ); assert.equal(later.delivery.outcome, "later"); assert.equal(later.inspiration.status, statusBeforeLater); @@ -165,124 +221,393 @@ test( owner.candidate.inspiration.version, "later must not mutate inspiration lifecycle/version" ); + } finally { + await Promise.all([ + captureA.close(), + captureB.close(), + flowA.close(), + flowB.close(), + ]); + } + }) +); + +test( + "external notification success followed by interruption is terminalized without resend after reopen", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => withIntegrationSchema(async ({ databaseUrl }) => { + const capture = new InspirationStore(databaseUrl); + let firstStore: FlowStore | null = new FlowStore(databaseUrl); + let reopenedStore: FlowStore | null = null; + try { + await capture.create({ + content: "at-most-once external notification", + tags: ["reliability"], + project: "EchoLog", + status: "inbox", + }); + await configureFlow(firstStore); + + const dispatchedAt = new Date("2026-08-24T09:00:00.000Z"); + const controller = new AbortController(); + let externalSends = 0; + const notificationKeys: Array<string | undefined> = []; + const interruptedService = new FlowService( + firstStore, + () => async (request) => { + externalSends += 1; + notificationKeys.push(request.dedupeKey); + // Model an external transport that succeeded just before the daemon + // was interrupted, so DB finalization never runs. + controller.abort(); + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + }, + () => dispatchedAt + ); + await assert.rejects( + interruptedService.nextManual("crash-after-send", controller.signal), + (error) => error instanceof Error && error.name === "AbortError" + ); + assert.equal(externalSends, 1); + const beforeRestart = await firstStore.listDeliveries(); + assert.equal(beforeRestart.deliveries.length, 1); + const original = beforeRestart.deliveries[0]!; + assert.equal(original.status, "dispatching"); + assert.deepEqual(notificationKeys, [ + `inspiration:${original.dedupeKey}`, + ]); - const oldScheduledAt = new Date("2026-08-24T10:00:00.000Z"); - const oldBucket = await flowA.reserveNext( - "scheduled", - "scheduled:60:old-bucket", - oldScheduledAt + await firstStore.close(); + firstStore = null; + reopenedStore = new FlowStore(databaseUrl); + let clock = new Date( + dispatchedAt.getTime() + FLOW_RESERVATION_LEASE_MS + 1 + ); + let nextNotification: "failed" | "sent" = "failed"; + const restartedService = new FlowService( + reopenedStore, + () => async (request) => { + externalSends += 1; + notificationKeys.push(request.dedupeKey); + return nextNotification === "sent" + ? { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + } + : { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "transport unavailable" }, + }, + }; + }, + () => clock ); - assert.equal(oldBucket.shouldNotify, true); - assert.ok(oldBucket.candidate); - assert.equal(oldBucket.candidate.delivery.status, "reserved"); - assert.equal(oldBucket.candidate.delivery.attempts, 1); - const afterBoundary = new Date("2026-08-24T11:01:00.000Z"); - const recovered = await flowB.reserveNext( - "scheduled", - "scheduled:60:new-bucket", - afterBoundary + const recovered = await restartedService.nextManual("crash-after-send"); + assert.equal(externalSends, 1, "the original ledger row must never be sent twice"); + assert.equal(recovered.shouldNotify, false); + assert.equal(recovered.candidate?.delivery.id, original.id); + assert.equal(recovered.candidate?.delivery.status, "failed"); + assert.equal(recovered.candidate?.delivery.error, FLOW_UNKNOWN_DISPATCH_ERROR); + assert.deepEqual(recovered.explanation, [ + "recovery:interrupted-dispatch-unknown", + ]); + + clock = new Date(clock.getTime() + 1_000); + const explicitFailure = await restartedService.nextManual("explicit-failure"); + assert.equal(externalSends, 2); + assert.equal(explicitFailure.candidate?.delivery.status, "failed"); + assert.ok(explicitFailure.candidate); + assert.notEqual(explicitFailure.candidate?.delivery.id, original.id); + assert.equal( + notificationKeys.at(-1), + `inspiration:${explicitFailure.candidate.delivery.dedupeKey}` ); - assert.equal(recovered.shouldNotify, true); - assert.ok(recovered.candidate); - assert.equal(recovered.candidate.delivery.id, oldBucket.candidate.delivery.id); - assert.equal(recovered.candidate.delivery.dedupeKey, "scheduled:60:old-bucket"); - assert.equal(recovered.candidate.delivery.attempts, 2); - assert.deepEqual(recovered.explanation, ["recovery:pending-delivery"]); - const finalizedRecovery = await flowB.finalizeNotification( - recovered.candidate.delivery.id, - recovered.candidate.delivery.version, - { - delivered: false, - channels: { - mac: { status: "disabled" }, - ntfy: { - status: "failed", - error: "ntfy notification timed out", - }, - }, - error: "notifications.send failed on all enabled channels", - at: afterBoundary, - } + clock = new Date(clock.getTime() + 1_000); + const duplicateFailure = await restartedService.nextManual("explicit-failure"); + assert.equal( + externalSends, + 2, + "a duplicate failed delivery must not call notifications.send again" ); - assert.equal(finalizedRecovery.status, "failed"); - assert.equal(finalizedRecovery.attempts, 2); - assert.deepEqual(finalizedRecovery.notificationChannels, { - mac: { status: "disabled" }, - ntfy: { status: "failed", error: "ntfy notification timed out" }, - }); - const failureFromLedger = (await flowA.listDeliveries()).find( - (item) => item.id === finalizedRecovery.id + assert.equal(duplicateFailure.shouldNotify, false); + assert.equal( + duplicateFailure.candidate?.delivery.id, + explicitFailure.candidate?.delivery.id + ); + assert.equal( + duplicateFailure.candidate?.delivery.dedupeKey, + explicitFailure.candidate?.delivery.dedupeKey ); - assert.deepEqual(failureFromLedger?.notificationChannels, { - mac: { status: "disabled" }, - ntfy: { status: "failed", error: "ntfy notification timed out" }, - }); - const retryAfterFailure = await flowA.reserveNext( - "scheduled", - "scheduled:60:retry-after-failure", - new Date("2026-08-24T12:02:00.000Z") + nextNotification = "sent"; + clock = new Date(clock.getTime() + 1_000); + const retry = await restartedService.nextManual("later-policy-bucket"); + assert.equal(externalSends, 3); + assert.equal(retry.candidate?.delivery.status, "sent"); + assert.ok(retry.candidate); + assert.notEqual( + retry.candidate.delivery.id, + explicitFailure.candidate.delivery.id, + "an explicit failure may be selected again only as a distinct attempt" + ); + assert.equal( + notificationKeys.at(-1), + `inspiration:${retry.candidate.delivery.dedupeKey}` ); - assert.equal(retryAfterFailure.shouldNotify, true); - assert.ok(retryAfterFailure.candidate); assert.notEqual( - retryAfterFailure.candidate.delivery.id, - finalizedRecovery.id, - "a failed attempt must remain eligible for a later dedupe bucket" + retry.candidate.delivery.dedupeKey, + explicitFailure.candidate.delivery.dedupeKey ); + assert.equal(notificationKeys.length, 3); + assert.equal(new Set(notificationKeys).size, notificationKeys.length); + } finally { + await capture.close(); + await firstStore?.close(); + await reopenedStore?.close(); + } + }) +); - let releaseSettingsLock!: () => void; - let settingsLocked!: () => void; - const lockAcquired = new Promise<void>((resolve) => { - settingsLocked = resolve; - }); - const releaseLock = new Promise<void>((resolve) => { - releaseSettingsLock = resolve; +test( + "scheduled reservation derives its key from the locked updated settings snapshot", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => withIntegrationSchema(async ({ admin, databaseUrl }) => { + const applicationName = `el_insp_wait_${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const waiterUrl = new URL(databaseUrl); + waiterUrl.searchParams.set("application_name", applicationName); + const capture = new InspirationStore(databaseUrl); + const waiter = new FlowStore(waiterUrl.toString()); + const blocker = postgres(databaseUrl, { max: 1 }); + let releaseSettingsLock!: () => void; + let lockAcquired!: () => void; + const acquired = new Promise<void>((resolve) => { + lockAcquired = resolve; + }); + const release = new Promise<void>((resolve) => { + releaseSettingsLock = resolve; + }); + let heldLock: Promise<unknown> | null = null; + try { + await capture.create({ + content: "settings snapshot race", + tags: ["race"], + project: "EchoLog", + status: "inbox", }); - const heldLock = blocker.begin(async (transaction) => { + const oldSettings = await configureFlow(waiter, { intervalMinutes: 60 }); + const newVersion = oldSettings.version + 1; + const newInterval = 90; + heldLock = blocker.begin(async (transaction) => { await transaction` - SELECT * FROM inspiration_flow_settings + SELECT id FROM inspiration_flow_settings WHERE id = 'default' FOR UPDATE `; - settingsLocked(); - await releaseLock; + lockAcquired(); + await release; + await transaction` + UPDATE inspiration_flow_settings + SET version = version + 1, + interval_minutes = ${newInterval}, + updated_at = NOW() + WHERE id = 'default' + `; }); - await lockAcquired; + await acquired; - const abortController = new AbortController(); - const abortedReservation = flowA.reserveNext( - "scheduled", - "scheduled:60:aborted-lock-wait", - new Date("2026-08-24T12:30:00.000Z"), - abortController.signal - ); - const abortedAssertion = assert.rejects( - abortedReservation, - (error) => error instanceof Error && error.name === "AbortError" - ); - abortController.abort(); + const now = new Date("2026-08-24T12:34:56.000Z"); + const reservation = waiter.reserveNext("scheduled", undefined, now); + await waitForDatabaseLock(admin, applicationName); releaseSettingsLock(); await heldLock; - await abortedAssertion; - const abortedRows = await blocker<{ count: number }[]>` - SELECT COUNT(*)::int AS count - FROM inspiration_flow_deliveries - WHERE dedupe_key = 'scheduled:60:aborted-lock-wait' - `; - assert.equal(abortedRows[0]?.count, 0); + heldLock = null; + + const result = await reservation; + assert.ok(result.candidate); + assert.equal(result.shouldNotify, true); + const bucket = Math.floor(now.getTime() / (newInterval * 60_000)); + assert.equal( + result.candidate.delivery.dedupeKey, + `scheduled:${newVersion}:${newInterval}:${bucket}` + ); + const persisted = await waiter.getSettings(); + assert.equal(persisted.version, newVersion); + assert.equal(persisted.intervalMinutes, newInterval); } finally { - await Promise.all([ - ...captureStores.map((store) => store.close()), - ...flowStores.map((store) => store.close()), - ]); - if (schemaCreated) { - await admin.unsafe(`DROP SCHEMA ${quotedSchema} CASCADE`); + releaseSettingsLock?.(); + await heldLock?.catch(() => undefined); + await Promise.all([capture.close(), waiter.close(), blocker.end()]); + } + }) +); + +test( + "delivery composite cursors traverse equal timestamps without skips or duplicates", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => withIntegrationSchema(async ({ databaseUrl }) => { + const capture = new InspirationStore(databaseUrl); + const flow = new FlowStore(databaseUrl); + const control = postgres(databaseUrl, { max: 1 }); + try { + const inspiration = await capture.create({ + content: "pagination anchor", + tags: ["pagination"], + project: "EchoLog", + status: "inbox", + }); + const equalAt = new Date("2026-08-24T09:00:00.000Z"); + const seeded = [ + { id: "pg_later", surfacedAt: new Date("2026-08-24T10:00:00.000Z") }, + ...["pg_equal_01", "pg_equal_02", "pg_equal_03", "pg_equal_04", "pg_equal_05", "pg_equal_06"] + .map((id) => ({ id, surfacedAt: equalAt })), + { id: "pg_earlier", surfacedAt: new Date("2026-08-24T08:00:00.000Z") }, + ]; + for (const row of seeded) { + await control` + INSERT INTO inspiration_flow_deliveries ( + id, inspiration_id, source, dedupe_key, status, surfaced_at, + error, created_at, updated_at + ) VALUES ( + ${row.id}, ${inspiration.id}, 'manual', ${`seed:${row.id}`}, + 'failed', ${row.surfacedAt}, 'seed failure', + ${row.surfacedAt}, ${row.surfacedAt} + ) + `; } - await blocker.end(); - await admin.end(); + + const expected = [...seeded] + .sort((left, right) => { + const byTime = right.surfacedAt.getTime() - left.surfacedAt.getTime(); + if (byTime !== 0) return byTime; + return left.id === right.id ? 0 : left.id < right.id ? 1 : -1; + }) + .map((row) => row.id); + const traversed: string[] = []; + const opaqueCursors: string[] = []; + let cursor: DeliveryCursor | undefined; + for (;;) { + const page = await flow.listDeliveries(3, cursor); + traversed.push(...page.deliveries.map((delivery) => delivery.id)); + if (!page.nextCursor) break; + const opaque = encodeDeliveryCursor(page.nextCursor); + opaqueCursors.push(opaque); + const decoded = decodeDeliveryCursor(opaque); + assert.ok(decoded); + assert.equal(decoded.id, page.nextCursor.id); + assert.equal( + decoded.surfacedAt.toISOString(), + page.nextCursor.surfacedAt.toISOString() + ); + cursor = decoded; + } + + assert.ok(opaqueCursors.length >= 2, "equal timestamps must cross page boundaries"); + assert.deepEqual(traversed, expected); + assert.equal(new Set(traversed).size, traversed.length); + + const equalTimestampBoundary = await flow.listDeliveries(20, { + surfacedAt: equalAt, + id: "pg_equal_04", + }); + assert.deepEqual( + equalTimestampBoundary.deliveries.map((delivery) => delivery.id), + ["pg_equal_03", "pg_equal_02", "pg_equal_01", "pg_earlier"] + ); + assert.equal(equalTimestampBoundary.nextCursor, null); + + const afterLast = await flow.listDeliveries(3, { + surfacedAt: new Date("2026-08-24T08:00:00.000Z"), + id: "pg_earlier", + }); + assert.deepEqual(afterLast.deliveries, []); + assert.equal(afterLast.nextCursor, null); + } finally { + await Promise.all([capture.close(), flow.close(), control.end()]); } - } + }) +); + +test( + "manual and scheduled failed deliveries are terminal and reject outcomes", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => withIntegrationSchema(async ({ databaseUrl }) => { + const capture = new InspirationStore(databaseUrl); + const flow = new FlowStore(databaseUrl); + try { + await capture.create({ + content: "failed outcome source boundary", + tags: ["outcome"], + project: "EchoLog", + status: "inbox", + }); + await configureFlow(flow); + let now = new Date("2026-08-24T14:00:00.000Z"); + let sends = 0; + const service = new FlowService( + flow, + () => async () => { + sends += 1; + return { + channels: { + mac: { status: "disabled" }, + ntfy: { status: "failed", error: "transport unavailable" }, + }, + }; + }, + () => now + ); + + const manual = await service.nextManual("manual-failed-outcome"); + assert.equal(manual.candidate?.delivery.status, "failed"); + assert.equal(manual.candidate?.delivery.source, "manual"); + assert.ok(manual.candidate); + await assert.rejects( + service.applyOutcome(manual.candidate.delivery.id, { + expectedDeliveryVersion: manual.candidate.delivery.version, + expectedInspirationVersion: manual.candidate.inspiration.version, + outcome: "viewed", + }), + (error) => error instanceof FlowStoreError && error.code === "INVALID_STATE" + ); + const manualLedger = (await flow.listDeliveries()).deliveries.find( + (delivery) => delivery.id === manual.candidate?.delivery.id + ); + assert.equal(manualLedger?.status, "failed"); + assert.equal(manualLedger?.outcome, null); + assert.equal(manualLedger?.outcomeAt, null); + + now = new Date(now.getTime() + 60 * 60_000); + const scheduled = await service.runScheduled(new AbortController().signal); + assert.equal(scheduled.candidate?.delivery.status, "failed"); + assert.equal(scheduled.candidate?.delivery.source, "scheduled"); + assert.ok(scheduled.candidate); + await assert.rejects( + service.applyOutcome(scheduled.candidate.delivery.id, { + expectedDeliveryVersion: scheduled.candidate.delivery.version, + expectedInspirationVersion: scheduled.candidate.inspiration.version, + outcome: "viewed", + }), + (error) => error instanceof FlowStoreError && error.code === "INVALID_STATE" + ); + const scheduledLedger = (await flow.listDeliveries()).deliveries.find( + (delivery) => delivery.id === scheduled.candidate?.delivery.id + ); + assert.equal(scheduledLedger?.status, "failed"); + assert.equal(scheduledLedger?.outcome, null); + assert.equal(scheduledLedger?.outcomeAt, null); + assert.equal(sends, 2); + } finally { + await Promise.all([capture.close(), flow.close()]); + } + }) ); diff --git a/tests/plugin-notification-host.test.ts b/tests/plugin-notification-host.test.ts index f39e468..6804040 100644 --- a/tests/plugin-notification-host.test.ts +++ b/tests/plugin-notification-host.test.ts @@ -86,7 +86,11 @@ test("denies notifications.send without its declared permission", async () => { }); test("returns the Core-owned send function to a permitted plugin", async () => { - const requests: Array<{ title: string; message: string }> = []; + const requests: Array<{ + title: string; + message: string; + dedupeKey?: string; + }> = []; const expected = { channels: { mac: { status: "sent" as const }, @@ -95,7 +99,11 @@ test("returns the Core-owned send function to a permitted plugin", async () => { }; let received: unknown; let receivedService: unknown; - const send = async (request: { title: string; message: string }) => { + const send = async (request: { + title: string; + message: string; + dedupeKey?: string; + }) => { requests.push(request); return expected; }; @@ -106,7 +114,11 @@ test("returns the Core-owned send function to a permitted plugin", async () => { async start(context) { const service = context.service<typeof send>("notifications.send"); receivedService = service; - received = await service({ title: "Reminder", message: "Stand up" }); + received = await service({ + title: "Reminder", + message: "Stand up", + dedupeKey: "test-plugin:delivery-01", + }); }, }], { "notifications.send": send } @@ -116,7 +128,11 @@ test("returns the Core-owned send function to a permitted plugin", async () => { assert.equal(pluginHost.list()[0]?.state, "ready"); assert.equal(receivedService, send); - assert.deepEqual(requests, [{ title: "Reminder", message: "Stand up" }]); + assert.deepEqual(requests, [{ + title: "Reminder", + message: "Stand up", + dedupeKey: "test-plugin:delivery-01", + }]); assert.equal(received, expected); }); From ac6fdb8ae5e863034067bc64a41ab0136eb45065 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 12:33:33 +0800 Subject: [PATCH 28/33] test(inspiration): align recovery diagnostics --- tests/inspiration.integration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/inspiration.integration.ts b/tests/inspiration.integration.ts index 69b5102..1a9ab9b 100644 --- a/tests/inspiration.integration.ts +++ b/tests/inspiration.integration.ts @@ -319,6 +319,7 @@ test( assert.equal(recovered.candidate?.delivery.error, FLOW_UNKNOWN_DISPATCH_ERROR); assert.deepEqual(recovered.explanation, [ "recovery:interrupted-dispatch-unknown", + "delivery:failed", ]); clock = new Date(clock.getTime() + 1_000); From 7420eec11f58c17573d9cd4bd1f73f95de99970f Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 13:15:05 +0800 Subject: [PATCH 29/33] fix(plugins): close finalization and host races --- plugins/inspiration/src/flow-store.ts | 105 ++++++++++++------- plugins/inspiration/src/flow.ts | 9 +- plugins/schedule/src/reminders.ts | 7 +- plugins/schedule/src/store.ts | 51 ++++++--- plugins/schedule/web/index.js | 6 ++ src/core/plugins/host.ts | 65 ++++++++---- tests/inspiration-flow.test.ts | 38 +++++++ tests/inspiration.integration.ts | 128 +++++++++++++++++++++++ tests/plugin-notification-host.test.ts | 81 +++++++++++++++ tests/schedule-job.test.ts | 91 +++++++++++++++- tests/schedule-web.test.ts | 42 ++++++++ tests/schedule.integration.ts | 138 ++++++++++++++++++++++++- tests/schedule.test.ts | 5 +- 13 files changed, 681 insertions(+), 85 deletions(-) diff --git a/plugins/inspiration/src/flow-store.ts b/plugins/inspiration/src/flow-store.ts index 0c2922e..002635a 100644 --- a/plugins/inspiration/src/flow-store.ts +++ b/plugins/inspiration/src/flow-store.ts @@ -561,48 +561,73 @@ export class FlowStore { async finalizeNotification( deliveryId: string, expectedVersion: number, - result: FlowNotificationFinalization + result: FlowNotificationFinalization, + signal?: AbortSignal ): Promise<FlowDelivery> { - const rows = result.delivered - ? await this.sql<DeliveryRow[]>` - UPDATE inspiration_flow_deliveries - SET status = 'sent', notified_at = ${result.at}, - notification_channel = NULL, - notification_channels = ${this.sql.json(result.channels)}, - error = NULL, - version = version + 1, updated_at = ${result.at} - WHERE id = ${deliveryId} AND version = ${expectedVersion} - AND status = 'dispatching' - RETURNING * - ` - : await this.sql<DeliveryRow[]>` - UPDATE inspiration_flow_deliveries - SET status = 'failed', - notification_channel = NULL, - notification_channels = ${result.channels === null - ? null - : this.sql.json(result.channels)}, - error = ${result.error}, - version = version + 1, updated_at = ${result.at} - WHERE id = ${deliveryId} AND version = ${expectedVersion} - AND status = 'dispatching' - RETURNING * - `; - if (rows[0]) return mapDelivery(rows[0]); + signal?.throwIfAborted(); + return this.sql.begin(async (transaction) => { + const currentRows = await transaction<DeliveryRow[]>` + SELECT * FROM inspiration_flow_deliveries + WHERE id = ${deliveryId} + FOR UPDATE + `; + signal?.throwIfAborted(); + const current = currentRows[0]; + if (!current) { + throw new FlowStoreError( + "NOT_FOUND", + `delivery ${deliveryId} not found`, + 404 + ); + } + if ( + current.version !== expectedVersion || + current.status !== "dispatching" + ) { + throw new FlowStoreError( + "VERSION_CONFLICT", + `delivery ${deliveryId} changed before notification finalization`, + 409, + current.version + ); + } - const currentRows = await this.sql<DeliveryRow[]>` - SELECT * FROM inspiration_flow_deliveries WHERE id = ${deliveryId} - `; - const current = currentRows[0]; - if (!current) { - throw new FlowStoreError("NOT_FOUND", `delivery ${deliveryId} not found`, 404); - } - throw new FlowStoreError( - "VERSION_CONFLICT", - `delivery ${deliveryId} changed before notification finalization`, - 409, - current.version - ); + signal?.throwIfAborted(); + const rows = result.delivered + ? await transaction<DeliveryRow[]>` + UPDATE inspiration_flow_deliveries + SET status = 'sent', notified_at = ${result.at}, + notification_channel = NULL, + notification_channels = ${transaction.json(result.channels)}, + error = NULL, + version = version + 1, updated_at = ${result.at} + WHERE id = ${deliveryId} AND version = ${expectedVersion} + AND status = 'dispatching' + RETURNING * + ` + : await transaction<DeliveryRow[]>` + UPDATE inspiration_flow_deliveries + SET status = 'failed', + notification_channel = NULL, + notification_channels = ${result.channels === null + ? null + : transaction.json(result.channels)}, + error = ${result.error}, + version = version + 1, updated_at = ${result.at} + WHERE id = ${deliveryId} AND version = ${expectedVersion} + AND status = 'dispatching' + RETURNING * + `; + signal?.throwIfAborted(); + if (rows[0]) return mapDelivery(rows[0]); + + throw new FlowStoreError( + "VERSION_CONFLICT", + `delivery ${deliveryId} changed before notification finalization`, + 409, + current.version + ); + }); } async listDeliveries( diff --git a/plugins/inspiration/src/flow.ts b/plugins/inspiration/src/flow.ts index 7c6485b..0419401 100644 --- a/plugins/inspiration/src/flow.ts +++ b/plugins/inspiration/src/flow.ts @@ -88,7 +88,8 @@ export interface FlowPersistence { finalizeNotification( deliveryId: string, expectedVersion: number, - result: FlowNotificationFinalization + result: FlowNotificationFinalization, + signal?: AbortSignal ): Promise<FlowDelivery>; listDeliveries( limit?: number, @@ -198,7 +199,8 @@ export class FlowService { // notification content, prompts, or replies. error: notificationFailureMessage(error), at: this.clock(), - } + }, + signal ); explainFailedDelivery(reserved); return reserved; @@ -218,7 +220,8 @@ export class FlowService { channels: notification.channels, error: noDeliveryMessage(notification), at: this.clock(), - } + }, + signal ); if (candidate.delivery.status === "failed") { explainFailedDelivery(reserved); diff --git a/plugins/schedule/src/reminders.ts b/plugins/schedule/src/reminders.ts index 3920986..ee8cb59 100644 --- a/plugins/schedule/src/reminders.ts +++ b/plugins/schedule/src/reminders.ts @@ -21,7 +21,8 @@ export interface ReminderStore { channelResults: NotificationSendResult["channels"] | null; failure: string | null; }, - completedAt?: Date + completedAt?: Date, + signal?: AbortSignal ): Promise<ReminderDelivery>; } @@ -179,7 +180,7 @@ export async function pollDueReminders( status: "failed", channelResults: null, failure: errorMessage(error), - }, new Date()); + }, new Date(), signal); summary.failed++; continue; } @@ -189,7 +190,7 @@ export async function pollDueReminders( status: outcome.status, channelResults: result.channels, failure: outcome.failure, - }, new Date()); + }, new Date(), signal); summary[outcome.status]++; } return summary; diff --git a/plugins/schedule/src/store.ts b/plugins/schedule/src/store.ts index 1b61a2c..4dcde4f 100644 --- a/plugins/schedule/src/store.ts +++ b/plugins/schedule/src/store.ts @@ -500,21 +500,44 @@ export class ScheduleStore { channelResults: NotificationSendResult["channels"] | null; failure: string | null; }, - completedAt = new Date() + completedAt = new Date(), + callerSignal?: AbortSignal ): Promise<ReminderDelivery> { - const [updated] = await this.db - .update(scheduleReminderDeliveries) - .set({ - ...input, - failure: input.failure?.slice(0, 1_000) ?? null, - completedAt, - }) - .where(and( - eq(scheduleReminderDeliveries.id, id), - eq(scheduleReminderDeliveries.status, "claimed") - )) - .returning(); - if (!updated) throw new Error(`Reminder delivery ${id} is not claimable`); + callerSignal?.throwIfAborted(); + const updated = await this.db.transaction(async (transaction) => { + callerSignal?.throwIfAborted(); + const [claimable] = await transaction + .select({ id: scheduleReminderDeliveries.id }) + .from(scheduleReminderDeliveries) + .where(and( + eq(scheduleReminderDeliveries.id, id), + eq(scheduleReminderDeliveries.status, "claimed") + )) + .for("update"); + // The row lock is the blocking wait in this transaction. Recheck after + // it returns and again immediately before the terminal state mutation. + callerSignal?.throwIfAborted(); + if (!claimable) throw new Error(`Reminder delivery ${id} is not claimable`); + callerSignal?.throwIfAborted(); + const [row] = await transaction + .update(scheduleReminderDeliveries) + .set({ + ...input, + failure: input.failure?.slice(0, 1_000) ?? null, + completedAt, + }) + .where(and( + eq(scheduleReminderDeliveries.id, id), + eq(scheduleReminderDeliveries.status, "claimed") + )) + .returning(); + // Keep the final post-write fence inside the transaction so an abort + // observed here rolls the terminal mutation back before commit. + callerSignal?.throwIfAborted(); + if (!row) throw new Error(`Reminder delivery ${id} is not claimable`); + return row; + }); + callerSignal?.throwIfAborted(); return reminderFromRow(updated); } diff --git a/plugins/schedule/web/index.js b/plugins/schedule/web/index.js index 875b971..d20071e 100644 --- a/plugins/schedule/web/index.js +++ b/plugins/schedule/web/index.js @@ -367,6 +367,7 @@ export async function activate({ let latestCalendar = { referenceKey, ...queryWindow(referenceKey) }; let observedSnapshot = ""; let renderedSnapshot = ""; + let snapshotRequestGeneration = 0; let fullLoadGeneration = 0; let refreshPromise = null; let mounted = true; @@ -447,16 +448,20 @@ export async function activate({ return { id: "schedule", async load() { + const requestGeneration = ++snapshotRequestGeneration; const snapshot = await fetchSnapshot(); if (!mounted) return {}; + if (requestGeneration !== snapshotRequestGeneration) return currentData(); fullLoadGeneration++; applySnapshot(snapshot, true); return currentData(); }, async loadLive() { if (!mounted) return {}; + const requestGeneration = ++snapshotRequestGeneration; const snapshot = await fetchSnapshot(); if (!mounted) return {}; + if (requestGeneration !== snapshotRequestGeneration) return currentData(); if (!renderedSnapshot) { applySnapshot(snapshot, true); } else { @@ -567,6 +572,7 @@ export async function activate({ }, async unmount() { mounted = false; + snapshotRequestGeneration++; stylesheet?.remove?.(); }, }; diff --git a/src/core/plugins/host.ts b/src/core/plugins/host.ts index e19638f..2acabf1 100644 --- a/src/core/plugins/host.ts +++ b/src/core/plugins/host.ts @@ -21,6 +21,7 @@ import type { PluginCommandRunner } from "./command-runner.js"; interface Runtime { definition: PluginDefinition; context: PluginContext; + setConfig(config: Readonly<Record<string, unknown>>): void; info: PluginRuntimeInfo; routes: PluginRoute[]; jobs: Map<string, PluginJobRuntime>; @@ -93,41 +94,38 @@ export class PluginHost { private readonly runtimes = new Map<string, Runtime>(); constructor(private readonly options: PluginHostOptions) { - for (const definition of options.definitions) { - const id = definition.manifest.id; + for (const [index, definition] of options.definitions.entries()) { + const manifest = definition.manifest as Partial<PluginDefinition["manifest"]>; + const id = typeof manifest?.id === "string" + ? manifest.id + : `invalid-plugin-${index + 1}`; if (this.runtimes.has(id)) throw new Error(`Duplicate plugin id: ${id}`); const configured = Object.hasOwn(options.configuration ?? {}, id); const settings = options.configuration?.[id]; const enabled = settings?.enabled ?? definition.defaultEnabled ?? false; - const mergedConfig = definition.normalizeConfig?.({ - ...(definition.defaultConfig ?? {}), - ...(settings?.config ?? {}), - }) ?? { - ...(definition.defaultConfig ?? {}), - ...(settings?.config ?? {}), - }; - const routes: PluginRoute[] = [...(definition.routes ?? [])]; - for (const route of routes) validateRoute(id, route); + const routes: PluginRoute[] = []; const reportSections: PluginReportSection[] = []; const jobs = new Map<string, PluginJobRuntime>(); const info: PluginRuntimeInfo = { id, - displayName: definition.manifest.displayName, - version: definition.manifest.version, - apiVersion: definition.manifest.apiVersion, + displayName: id, + version: "", + apiVersion: "", configured, enabled, state: enabled ? "validating" : "disabled", - capabilities: [...definition.manifest.capabilities], - permissions: [...definition.manifest.permissions], - webEntry: definition.manifest.entries.web, + capabilities: [], + permissions: [], failureCount: 0, }; + let contextConfig = freezeConfig({}); const context: PluginContext = { pluginId: id, - config: freezeConfig(mergedConfig), + get config() { + return contextConfig; + }, logger: options.logger, registerRoute: (route) => { validateRoute(id, route); @@ -150,7 +148,7 @@ export class PluginHost { reportSections.push(section); }, exec: (request: PluginCommandRequest, signal?: AbortSignal) => { - if (!definition.manifest.permissions.includes("process:exec")) { + if (!info.permissions.includes("process:exec")) { throw new PluginError( "PLUGIN_DEPENDENCY_MISSING", `Plugin ${id} has not declared process:exec`, @@ -165,7 +163,7 @@ export class PluginHost { const requiredPermission = SERVICE_PERMISSIONS[name]; if ( requiredPermission && - !definition.manifest.permissions.includes(requiredPermission) + !info.permissions.includes(requiredPermission) ) { throw new PluginError( "PLUGIN_DEPENDENCY_MISSING", @@ -185,6 +183,9 @@ export class PluginHost { this.runtimes.set(id, { definition, context, + setConfig(config) { + contextConfig = config; + }, info, routes, jobs, @@ -209,11 +210,35 @@ export class PluginHost { if (manifestErrors.length > 0) { throw new Error(manifestErrors.join("; ")); } + const manifest = runtime.definition.manifest; + runtime.info.displayName = manifest.displayName; + runtime.info.version = manifest.version; + runtime.info.apiVersion = manifest.apiVersion; + runtime.info.capabilities = [...manifest.capabilities]; + runtime.info.permissions = [...manifest.permissions]; + runtime.info.webEntry = manifest.entries.web; + + // Static compatibility routes remain installed for a valid disabled + // plugin so callers receive the Host's structured disabled response. + // Invalid manifests never reach this point, keeping their definitions + // inert inside the per-plugin isolation boundary. + const routes = [...(runtime.definition.routes ?? [])]; + for (const route of routes) validateRoute(runtime.info.id, route); + runtime.routes.push(...routes); if (!runtime.info.enabled) { this.setState(runtime, "disabled"); continue; } + const settings = this.options.configuration?.[runtime.info.id]; + const rawConfig = { + ...(runtime.definition.defaultConfig ?? {}), + ...(settings?.config ?? {}), + }; + const mergedConfig = runtime.definition.normalizeConfig?.(rawConfig) + ?? rawConfig; + runtime.setConfig(freezeConfig(mergedConfig)); + const configErrors = runtime.definition.validateConfig?.( runtime.context.config ) ?? []; diff --git a/tests/inspiration-flow.test.ts b/tests/inspiration-flow.test.ts index afab2fd..eccfad4 100644 --- a/tests/inspiration-flow.test.ts +++ b/tests/inspiration-flow.test.ts @@ -378,6 +378,44 @@ test("service calls notifications.send with text and the stable delivery dedupe }]); assert.equal(finalized.length, 1); assert.deepEqual((finalized[0] as unknown[]).slice(0, 2), ["delivery-a", 2]); + assert.equal((finalized[0] as unknown[])[3], controller.signal); +}); + +test("service forwards caller abort to pending finalization", async () => { + const controller = new AbortController(); + let markFinalizeStarted!: () => void; + const finalizeStarted = new Promise<void>((resolve) => { + markFinalizeStarted = resolve; + }); + const store = persistence({ + async finalizeNotification(_id, _version, _result, signal) { + assert.equal(signal, controller.signal); + markFinalizeStarted(); + return new Promise<FlowDelivery>((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }, + }); + const service = new FlowService(store, () => async () => ({ + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }), () => NOW); + + const deliveryAttempt = service.nextManual( + "abort-during-finalize", + controller.signal + ); + const rejected = assert.rejects( + deliveryAttempt, + (error) => error instanceof Error && error.name === "AbortError" + ); + await finalizeStarted; + controller.abort(); + await rejected; }); test("sent duplicate is not re-sent after the pre-send claim", async () => { diff --git a/tests/inspiration.integration.ts b/tests/inspiration.integration.ts index 1a9ab9b..eadab18 100644 --- a/tests/inspiration.integration.ts +++ b/tests/inspiration.integration.ts @@ -379,6 +379,134 @@ test( }) ); +test( + "caller abort rolls back blocked sent and failed PostgreSQL finalization", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => withIntegrationSchema(async ({ admin, databaseUrl }) => { + const applicationName = `el_insp_fin_${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const flowUrl = new URL(databaseUrl); + flowUrl.searchParams.set("application_name", applicationName); + const capture = new InspirationStore(databaseUrl); + const flow = new FlowStore(flowUrl.toString()); + const blocker = postgres(databaseUrl, { max: 1 }); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + + const runCase = async (outcome: "sent" | "failed") => { + const controller = new AbortController(); + let markNotificationDone!: () => void; + let releaseNotification!: () => void; + let markLockAcquired!: () => void; + let releaseLock!: () => void; + const notificationDone = new Promise<void>((resolve) => { + markNotificationDone = resolve; + }); + const notificationRelease = new Promise<void>((resolve) => { + releaseNotification = resolve; + }); + const lockAcquired = new Promise<void>((resolve) => { + markLockAcquired = resolve; + }); + const lockRelease = new Promise<void>((resolve) => { + releaseLock = resolve; + }); + let heldLock: Promise<unknown> | null = null; + let observedRejection: Promise<void> | null = null; + const dedupeKey = `blocked-finalize-${outcome}`; + + try { + const service = new FlowService( + flow, + () => async () => { + markNotificationDone(); + await notificationRelease; + if (outcome === "failed") { + throw new Error("controlled notification failure"); + } + return { + channels: { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + }, + }; + } + ); + const attempt = service.nextManual(dedupeKey, controller.signal); + observedRejection = assert.rejects( + attempt, + (error) => error instanceof Error && error.name === "AbortError" + ); + await notificationDone; + + const deliveryRows = await blocker<{ id: string; status: string }[]>` + SELECT id, status FROM inspiration_flow_deliveries + WHERE dedupe_key = ${`manual:${dedupeKey}`} + `; + const delivery = deliveryRows[0]; + assert.ok(delivery); + assert.equal(delivery.status, "dispatching"); + + heldLock = blocker.begin(async (transaction) => { + await transaction` + SELECT id FROM inspiration_flow_deliveries + WHERE id = ${delivery.id} + FOR UPDATE + `; + markLockAcquired(); + await lockRelease; + }); + await lockAcquired; + releaseNotification(); + await waitForDatabaseLock(admin, applicationName); + + controller.abort(); + releaseLock(); + await heldLock; + heldLock = null; + await observedRejection; + observedRejection = null; + await new Promise<void>((resolve) => setImmediate(resolve)); + + const afterAbort = await blocker<{ status: string }[]>` + SELECT status FROM inspiration_flow_deliveries + WHERE id = ${delivery.id} + `; + assert.equal( + afterAbort[0]?.status, + "dispatching", + `${outcome} finalization must not write after caller abort` + ); + } finally { + controller.abort(); + releaseNotification?.(); + releaseLock?.(); + await heldLock?.catch(() => undefined); + await observedRejection?.catch(() => undefined); + } + }; + + try { + await capture.create({ + content: "blocked notification finalization", + tags: ["reliability"], + project: "EchoLog", + status: "inbox", + }); + await configureFlow(flow); + await runCase("sent"); + await runCase("failed"); + await new Promise<void>((resolve) => setImmediate(resolve)); + assert.deepEqual(unhandled, []); + } finally { + process.off("unhandledRejection", onUnhandled); + await Promise.all([capture.close(), flow.close(), blocker.end()]); + } + }) +); + test( "scheduled reservation derives its key from the locked updated settings snapshot", { skip: !testDatabaseUrl, timeout: 30_000 }, diff --git a/tests/plugin-notification-host.test.ts b/tests/plugin-notification-host.test.ts index 6804040..e7bafde 100644 --- a/tests/plugin-notification-host.test.ts +++ b/tests/plugin-notification-host.test.ts @@ -284,6 +284,87 @@ test("degrades an invalid disabled manifest without running lifecycle hooks", as assert.equal(lifecycle.healthyStop, 1); }); +test("isolates a disabled manifest missing permissions before later plugins", async () => { + const lifecycle = { + invalidMigrations: 0, + invalidRegister: 0, + invalidStart: 0, + invalidStop: 0, + healthyMigrations: 0, + healthyStart: 0, + healthyStop: 0, + }; + const validManifest = manifest("notification-missing-permissions"); + const { + permissions: _permissions, + ...missingPermissions + } = validManifest; + const pluginHost = new PluginHost({ + definitions: [ + { + manifest: missingPermissions as PluginManifest, + defaultEnabled: false, + register() { + lifecycle.invalidRegister++; + }, + start() { + lifecycle.invalidStart++; + }, + stop() { + lifecycle.invalidStop++; + }, + }, + { + manifest: manifest("notification-healthy-after-missing-permissions"), + defaultEnabled: true, + start() { + lifecycle.healthyStart++; + }, + stop() { + lifecycle.healthyStop++; + }, + }, + ], + logger, + migrationRunner: async (pluginId) => { + if (pluginId === "notification-missing-permissions") { + lifecycle.invalidMigrations++; + } else { + lifecycle.healthyMigrations++; + } + }, + commandRunner: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + }); + + await pluginHost.initialize(); + + const plugins = Object.fromEntries( + pluginHost.list().map((plugin) => [plugin.id, plugin]) + ); + const invalid = plugins["notification-missing-permissions"]; + assert.equal(invalid?.enabled, false); + assert.equal(invalid?.state, "degraded"); + assert.equal(invalid?.error?.code, "PLUGIN_DEGRADED"); + assert.match(invalid?.error?.message ?? "", /permissions must be an array/); + assert.equal( + plugins["notification-healthy-after-missing-permissions"]?.state, + "ready" + ); + assert.deepEqual(lifecycle, { + invalidMigrations: 0, + invalidRegister: 0, + invalidStart: 0, + invalidStop: 0, + healthyMigrations: 1, + healthyStart: 1, + healthyStop: 0, + }); + + await pluginHost.stop(); + assert.equal(lifecycle.invalidStop, 0); + assert.equal(lifecycle.healthyStop, 1); +}); + test("isolates an unavailable notification service from later plugins", async () => { let healthyStarted = false; const pluginHost = host([ diff --git a/tests/schedule-job.test.ts b/tests/schedule-job.test.ts index e5f1317..3c67476 100644 --- a/tests/schedule-job.test.ts +++ b/tests/schedule-job.test.ts @@ -120,7 +120,8 @@ async function waitFor( class ObservedReminderStore implements ReminderStore { constructor( - private readonly claimPending?: Deferred<ReminderDelivery | null> + private readonly claimPending?: Deferred<ReminderDelivery | null>, + private readonly finishPending?: Deferred<void> ) {} readonly item = scheduleItem(); @@ -136,6 +137,8 @@ class ObservedReminderStore implements ReminderStore { dueCalls = 0; claimCalls = 0; readonly claimSignals: AbortSignal[] = []; + readonly finishSignals: AbortSignal[] = []; + finishAttempts = 0; private claimPendingUsed = false; async dueReminders(): Promise<DueReminder[]> { @@ -180,8 +183,15 @@ class ObservedReminderStore implements ReminderStore { status: "sent" | "failed"; channelResults: NotificationSendResult["channels"] | null; failure: string | null; - } + }, + _completedAt?: Date, + signal?: AbortSignal ): Promise<ReminderDelivery> { + signal?.throwIfAborted(); + this.finishAttempts++; + if (signal) this.finishSignals.push(signal); + if (this.finishPending) await this.finishPending.promise; + signal?.throwIfAborted(); this.finishCalls.push({ id, ...input }); this.terminalCounters[input.status]++; const delivery = [...this.deliveries.values()].find((entry) => entry.id === id); @@ -214,9 +224,13 @@ function jobHarness( intervalMs: number; timeoutMs: number; claimPending?: Deferred<ReminderDelivery | null>; + finishPending?: Deferred<void>; } ): JobHarness { - const store = new ObservedReminderStore(options.claimPending); + const store = new ObservedReminderStore( + options.claimPending, + options.finishPending + ); let runs = 0; const completedRunIds: number[] = []; const summaries: Array<{ runId: number; result: ReminderPollResult }> = []; @@ -438,6 +452,77 @@ for (const [index, scenario] of stopSettlements.entries()) { }); } +const blockedFinalizationSettlements: Array<{ + name: string; + send: NotificationSend; + settle(pending: Deferred<void>): void; +}> = [ + { + name: "sent finalization resolving late", + send: async () => sentResult, + settle: (pending) => pending.resolve(), + }, + { + name: "failed finalization resolving late", + send: async () => { throw new Error("provider unavailable before finalization"); }, + settle: (pending) => pending.resolve(), + }, + { + name: "finalization rejecting late", + send: async () => sentResult, + settle: (pending) => pending.reject(new Error("database rejected after timeout")), + }, +]; + +for (const [index, scenario] of blockedFinalizationSettlements.entries()) { + test(`PluginHost timeout revokes Schedule ${scenario.name}`, { + timeout: 3_000, + }, async () => { + const pendingFinish = deferred<void>(); + const unhandled = captureUnhandledRejections(); + const harness = jobHarness( + `schedule-finalization-timeout-${index}`, + scenario.send, + { intervalMs: 8, timeoutMs: 20, finishPending: pendingFinish } + ); + + try { + await harness.host.initialize(); + await waitFor( + () => harness.store.finishAttempts === 1, + "the first Host run did not reach finalization" + ); + assert.equal( + harness.store.finishSignals[0], + harness.store.claimSignals[0], + "Schedule must propagate the exact Host signal into finalization" + ); + await waitFor( + () => + harness.host.list()[0]?.error?.code === "PLUGIN_TIMEOUT" && + harness.getRuns() >= 2, + "Host did not release the blocked finalization" + ); + assert.equal(harness.store.finishSignals[0]?.aborted, true); + assertRetainedClaim(harness.store); + + scenario.settle(pendingFinish); + await waitFor( + () => harness.getCompletedRunIds().includes(1), + "the late finalization continuation did not settle" + ); + await new Promise<void>((resolve) => setImmediate(resolve)); + + assertRetainedClaim(harness.store); + assert.deepEqual(unhandled.reasons, []); + } finally { + if (!pendingFinish.settled) pendingFinish.resolve(); + await harness.host.stop(); + unhandled.stop(); + } + }); +} + test("PluginHost timeout isolates a late claim from Schedule persistence", { timeout: 3_000, }, async () => { diff --git a/tests/schedule-web.test.ts b/tests/schedule-web.test.ts index e7d99ca..69842a0 100644 --- a/tests/schedule-web.test.ts +++ b/tests/schedule-web.test.ts @@ -298,6 +298,48 @@ test("Schedule coalesces overlapping live snapshot refreshes", async () => { assert.equal(refreshCalls, 2, "the acknowledged queued snapshot must remain stable"); }); +test("Schedule ignores an older live response that resolves after a newer request", async () => { + const base = item({ title: "v1", version: 1 }); + const older = deferred<ReturnType<typeof item>[]>(); + const newer = deferred<ReturnType<typeof item>[]>(); + let apiCalls = 0; + let refreshCalls = 0; + const contribution = await activate({ + now: () => NOW, + api: async () => { + apiCalls++; + if (apiCalls === 1) return [base]; + if (apiCalls === 2) return older.promise; + return newer.promise; + }, + refresh: async () => { refreshCalls++; }, + }); + await contribution.load(); + + const olderLive = contribution.loadLive(); + const newerLive = contribution.loadLive(); + newer.resolve([{ ...base, title: "v3 newest", version: 3 }]); + const newestData = await newerLive; + assert.equal(newestData.scheduleItems[0].title, "v3 newest"); + assert.equal(refreshCalls, 1); + + older.resolve([{ ...base, title: "v2 stale", version: 2 }]); + const staleResult = await olderLive; + assert.equal( + staleResult.scheduleItems[0].title, + "v3 newest", + "the stale request must return, rather than replace, the latest snapshot" + ); + assert.equal(refreshCalls, 1, "a stale response must not request another render"); + + const html = contribution.renderFace( + { type: "schedule-overview" }, + { ...renderContext([]), data: {} } + ); + assert.match(html, /v3 newest/); + assert.equal(html.includes("v2 stale"), false); +}); + test("Schedule does not run a queued refresh after focus begins mid-refresh", async () => { const base = item({ title: "v1", version: 1 }); const responses = [ diff --git a/tests/schedule.integration.ts b/tests/schedule.integration.ts index 01e78b1..c74e901 100644 --- a/tests/schedule.integration.ts +++ b/tests/schedule.integration.ts @@ -124,6 +124,43 @@ async function holdScheduleItemLock( }; } +async function holdReminderDeliveryLock( + connection: ReturnType<typeof postgres>, + deliveryId: string +): Promise<HeldRowLock> { + let releaseLock!: () => void; + let resolveLocked!: () => void; + let rejectLocked!: (error: unknown) => void; + let released = false; + const releaseRequested = new Promise<void>((resolve) => { + releaseLock = resolve; + }); + const locked = new Promise<void>((resolve, reject) => { + resolveLocked = resolve; + rejectLocked = reject; + }); + const settled = connection.begin(async (transaction) => { + await transaction` + SELECT id + FROM schedule_reminder_deliveries + WHERE id = ${deliveryId} + FOR UPDATE + `; + resolveLocked(); + await releaseRequested; + }); + void settled.catch(rejectLocked); + await locked; + return { + release() { + if (released) return; + released = true; + releaseLock(); + }, + settled, + }; +} + async function waitForBlockedApplication( admin: ReturnType<typeof postgres>, applicationName: string @@ -140,7 +177,7 @@ async function waitForBlockedApplication( if ((activity?.blocked ?? 0) > 0) return; await new Promise((resolve) => setTimeout(resolve, 5)); } - throw new Error(`claim connection ${applicationName} did not block on the row lock`); + throw new Error(`Schedule connection ${applicationName} did not block on the row lock`); } const logger = { @@ -310,6 +347,105 @@ test( } ); +test( + "Schedule rolls back blocked sent and failed finalizations after caller abort", + { skip: !testDatabaseUrl, timeout: 30_000 }, + async () => { + if (!testDatabaseUrl) return; + const schema = testSchemaName(); + const quotedSchema = quoteTestSchema(schema); + const scopedDatabaseUrl = databaseUrlForSchema(testDatabaseUrl, schema); + const applicationName = `el_schedule_finalize_${randomUUID().slice(0, 12)}`; + const admin = postgres(testDatabaseUrl, { max: 1 }); + const locker = postgres(scopedDatabaseUrl, { max: 1 }); + const finalizationStore = new ScheduleStore( + databaseUrlForSchema(testDatabaseUrl, schema, applicationName) + ); + const observerStore = new ScheduleStore(scopedDatabaseUrl); + const heldLocks: HeldRowLock[] = []; + let schemaCreated = false; + const unhandledReasons: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => unhandledReasons.push(reason); + process.on("unhandledRejection", onUnhandledRejection); + + try { + await admin.unsafe(`CREATE SCHEMA ${quotedSchema}`); + schemaCreated = true; + const migrationRunner = createPluginMigrationRunner(scopedDatabaseUrl); + await migrationRunner("schedule", schedulePlugin.migrations ?? []); + + const dueAt = new Date("2026-08-26T01:00:00Z"); + const attemptedAt = new Date("2026-08-26T02:00:00Z"); + for (const status of ["sent", "failed"] as const) { + const item = await observerStore.create({ + title: `Blocked ${status} finalization`, + description: null, + scheduledStartAt: dueAt, + scheduledEndAt: null, + timezone: "UTC", + priority: 0, + nextReminderAt: dueAt, + }); + const claimed = await finalizationStore.claimReminder( + item.id, + dueAt, + attemptedAt + ); + assert.ok(claimed); + const heldLock = await holdReminderDeliveryLock(locker, claimed.id); + heldLocks.push(heldLock); + const controller = new AbortController(); + const abortReason = new DOMException( + `Host released blocked ${status} finalization`, + "AbortError" + ); + const finalization = finalizationStore.finishReminder( + claimed.id, + { + status, + channelResults: status === "sent" ? { + mac: { status: "sent" }, + ntfy: { status: "disabled" }, + } : null, + failure: status === "failed" ? "provider unavailable" : null, + }, + new Date("2026-08-26T02:00:01Z"), + controller.signal + ); + const rejected = assert.rejects( + finalization, + (error: unknown) => error === abortReason + ); + await waitForBlockedApplication(admin, applicationName); + controller.abort(abortReason); + heldLock.release(); + await heldLock.settled; + await rejected; + + const [ledger] = await observerStore.listReminders({ + itemId: item.id, + limit: 10, + }); + assert.equal(ledger?.status, "claimed"); + assert.equal(ledger?.completedAt, null); + assert.equal(ledger?.channelResults, null); + assert.equal(ledger?.failure, null); + } + await new Promise<void>((resolve) => setImmediate(resolve)); + assert.deepEqual(unhandledReasons, []); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + for (const lock of heldLocks) lock.release(); + await Promise.allSettled(heldLocks.map(({ settled }) => settled)); + await finalizationStore.close(); + await observerStore.close(); + await locker.end(); + if (schemaCreated) await admin.unsafe(`DROP SCHEMA ${quotedSchema} CASCADE`); + await admin.end(); + } + } +); + test( "Schedule persists CAS transitions, range routes, and at-most-once reminder ledgers", { skip: !testDatabaseUrl, timeout: 30_000 }, diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts index 3c91396..beee0d4 100644 --- a/tests/schedule.test.ts +++ b/tests/schedule.test.ts @@ -370,8 +370,11 @@ class MemoryReminderStore { status: "sent" | "failed"; channelResults: NotificationSendResult["channels"] | null; failure: string | null; - } + }, + _completedAt?: Date, + signal?: AbortSignal ): Promise<ReminderDelivery> { + signal?.throwIfAborted(); this.state.terminalWrites.push({ id, status: input.status }); const entry = [...this.state.deliveries.values()].find((value) => value.id === id); if (!entry || entry.status !== "claimed") throw new Error("not claimable"); From 29cb75c1ec0d8a05c15b8bfb718c948f615a87e1 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 13:16:38 +0800 Subject: [PATCH 30/33] docs(trellis): record PR 36 final review --- .../check.jsonl | 5 ++ .../design.md | 36 ++++++++++++ .../implement.jsonl | 6 ++ .../implement.md | 14 +++++ .../prd.md | 38 +++++++++++++ .../research/branch-state.md | 15 +++++ .../research/verification.md | 57 +++++++++++++++++++ .../task.json | 26 +++++++++ 8 files changed, 197 insertions(+) create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/check.jsonl create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/design.md create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/implement.jsonl create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/implement.md create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/prd.md create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/research/verification.md create mode 100644 .trellis/tasks/08-26-pr36-final-integration-review/task.json diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/check.jsonl b/.trellis/tasks/08-26-pr36-final-integration-review/check.jsonl new file mode 100644 index 0000000..e0210ff --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/check.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/backend/plugin-api-guidelines.md","reason":"Review final Plugin API service and permission compliance."} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Review Abort, timeout, ledger and integration-test coverage."} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Review live refresh, focus and unmount behavior."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Check final cross-layer consistency and public contracts."} +{"file":".trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md","reason":"Compare final ancestry and content with the captured baseline."} diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/design.md b/.trellis/tasks/08-26-pr36-final-integration-review/design.md new file mode 100644 index 0000000..55954e3 --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/design.md @@ -0,0 +1,36 @@ +# Design: PR #36 final integration and re-review + +## Inputs + +- `codex/plugins-integration`: existing PR branch with the initial bundled plugins, + notification review merge, and the first Schedule Abort review merge. +- `codex/inspiration-plugin`: verified staged Inspiration P2/live-client changes. +- `codex/schedule-plugin`: additive commits `f91b8c7` and `058c1ba`. +- `codex/plugin-notification-service`: already merged through `3bc3c32`. + +## Integration strategy + +1. Commit the already-staged Inspiration change set without editing its content. +2. Merge Schedule through its branch head so ancestry and review-fix commits remain + visible. +3. Integrate the new Inspiration work commit without importing the branch's two + task-layout revert commits. Resolve Trellis task paths into the integration + branch's existing archive layout and keep the new PR integration task active. +4. Inspect the resulting first-parent and content diff against the remote PR head. + +No history rewriting or force push is allowed. The remote update is a normal push +from the checked-out `codex/plugins-integration` worktree. + +## Verification and review + +Run focused plugin/Host/PostgreSQL tests when available, then the complete root +test, typecheck, build, and diff check. A separate check agent reviews the final +integrated diff and may report findings, but integration conflict resolution stays +owned by the main agent. After a clean push, verify the PR head by GitHub API, +confirm CI is queued/running, and post the exact `@codex review` comment. + +## Rollback + +Before push, integration commits are append-only and can be corrected by further +commits. If validation fails, do not push. After push, do not rewrite history; +append a corrective commit and request review again. diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/implement.jsonl b/.trellis/tasks/08-26-pr36-final-integration-review/implement.jsonl new file mode 100644 index 0000000..9f0a527 --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/implement.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/backend/plugin-api-guidelines.md","reason":"Preserve notification service, permission, Abort and plugin isolation contracts during integration."} +{"file":".trellis/spec/backend/error-handling.md","reason":"Keep structured error semantics across merged backend changes."} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Apply timeout, late-continuation and durable delivery rules."} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Preserve snapshot-aware live contribution behavior."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Audit SDK, Host, plugin, CLI and Web boundaries together."} +{"file":".trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md","reason":"Use the captured branch ancestry and dirty-state baseline."} diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/implement.md b/.trellis/tasks/08-26-pr36-final-integration-review/implement.md new file mode 100644 index 0000000..1672a92 --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/implement.md @@ -0,0 +1,14 @@ +# Implementation plan + +- [x] Snapshot all participating branch heads and dirty worktrees. +- [x] Commit the staged Inspiration fixes on `codex/inspiration-plugin`. +- [x] Merge the latest Schedule branch head into `codex/plugins-integration`. +- [x] Integrate the Inspiration work commit while preserving archived task layout. +- [x] Audit the combined diff for service/manifest/Abort/live-refresh/timezone and + CLI/Web contract regressions. +- [x] Run focused tests, PostgreSQL integration, full test/typecheck/build and + `git diff --check`. +- [x] Dispatch an independent final check and resolve verified P0/P1/P2 findings. +- [ ] Commit Trellis/spec bookkeeping, archive this task, and record the session. +- [ ] Push `codex/plugins-integration`, verify PR #36 head/CI, and comment exactly + `@codex review`. diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/prd.md b/.trellis/tasks/08-26-pr36-final-integration-review/prd.md new file mode 100644 index 0000000..68b81c5 --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/prd.md @@ -0,0 +1,38 @@ +# PR #36 最终集成与 Codex 复审 + +## Goal + +将已经完成独立验证的 Plugin Notification、Schedule 与 Inspiration 审阅修复 +安全整合到 PR #36 的 `codex/plugins-integration` 分支,推送最新提交并针对最新 +head 重新触发 Codex Code Review。 + +## Requirements + +- 保留 PR #36 当前历史;只追加 commit/merge commit,不 amend、rebase 或强推。 +- 先将 Inspiration worktree 已暂存且验证通过的改动提交到 + `codex/inspiration-plugin`,不得丢失或混入无关用户改动。 +- 整合 Schedule commits `f91b8c7`、`058c1ba`;保留已经进入集成分支的 + notification review fixes `fdd22d9` 及其 Trellis 收尾提交。 +- Inspiration 的旧任务在集成分支保持归档形态,不重新制造同名 active task; + 必须保留其 PR review/spec/测试更新。 +- 解决整合冲突时保持 Plugin API v1、权限、Abort、notification result、live + refresh、timezone、disabled/degraded 隔离和 CLI/Web 契约不回退。 +- 推送前运行 root `pnpm test`、`pnpm typecheck`、`pnpm build`、相关 PostgreSQL + integration 及 `git diff --check`。 +- 推送后确认 PR #36 head 与本地一致、CI 已触发,并在 PR 评论中精确发送 + `@codex review`;不得合并 PR 或 main。 + +## Acceptance Criteria + +- [x] Inspiration 修复存在独立追加 commit,原 worktree 干净。 +- [x] `codex/plugins-integration` 包含 notification、Schedule 和 Inspiration 的 + 全部已验证修复,且 Trellis active/archive 路径无重复漂移。 +- [x] 全量与定向验证通过,无未解决的本地 P0/P1/P2 审查发现。 +- [ ] 集成分支工作树干净并成功 push 到 PR #36 的远端 head。 +- [ ] PR 最新 head 已触发 CI,并已精确评论 `@codex review`。 +- [x] 不 merge PR/main,不重写历史,不覆盖其他 worktree 的用户改动。 + +## Notes + +- GitHub PR: https://github.com/CubePlus1/echolog/pull/36 +- 开始时远端 PR head 为 `d384adb`;本地集成分支 head 为 `fde7b17`。 diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md b/.trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md new file mode 100644 index 0000000..3ec7730 --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md @@ -0,0 +1,15 @@ +# PR #36 branch state at integration start + +Captured 2026-08-26 before mutation: + +- remote PR branch: `d384adb328bd39ac364fbe8a36e8a20af55bb768` +- local integration branch: `fde7b1799e83ec50624091c46b9d4dbd9363d7a6` +- notification branch: `c0712aef685af0efe322532322e8506e52c8f3c4` +- Schedule branch: `058c1ba7183c5e82ba9154a88d79ad2512aea8d0` +- Inspiration branch HEAD: `4a64ba1f5d3fdda4ed6cf9cf49f9b16597e42c6b` + with 44 staged paths containing the verified review fixes. + +The integration branch already contains the notification branch head and Schedule +through `ea4119d`. It does not contain Schedule `f91b8c7`/`058c1ba` or the staged +Inspiration changes. Inspiration active task paths differ from the integration +branch's archived paths and require explicit reconciliation. diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/research/verification.md b/.trellis/tasks/08-26-pr36-final-integration-review/research/verification.md new file mode 100644 index 0000000..6cdc7e1 --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/research/verification.md @@ -0,0 +1,57 @@ +# Final integration verification + +## Integrated commits + +- Inspiration worktree commit: `6e394e4` +- Schedule branch head: `058c1ba` (including `f91b8c7`) +- Integration Schedule merge: `742059b` +- Integration Inspiration commit: `19f8979` +- PostgreSQL expectation correction: `ac6fdb8` +- Final caller-abort, live-race, and Host isolation fix: `7420eec` + +The notification review branch was already integrated through merge `3bc3c32` +and includes `fdd22d9`. + +## Validation + +- `pnpm build`: pass +- `pnpm test`: 211 passed, 1 skipped, 0 failed +- `pnpm typecheck`: pass +- PostgreSQL integrations with a dedicated `echolog_test` database: 13/13 pass +- `pnpm install --frozen-lockfile`: already up to date +- `git diff --check origin/codex/plugins-integration...HEAD`: pass +- no merge conflict markers and no active duplicate Inspiration/Schedule review + task paths in the committed tree + +The first integration invocation ran before workspace plugin `dist` artifacts were +rebuilt and therefore caused CLI/MCP import failures. Running the documented build +order resolved all eight failures. The first real PostgreSQL run then found one +outdated test expectation: recovery deliberately reports both the specific +`recovery:interrupted-dispatch-unknown` reason and the generic `delivery:failed` +diagnostic. Production behavior matched the unit contract; the integration +expectation was updated and independently diagnosed as test drift. + +The first final integration review found two P1 caller-abort finalization gaps +(Schedule and Inspiration) and one P2 stale Schedule live-response race. The +fixes propagate the exact Host signal into transactional row-lock finalization, +keep the post-write abort fence inside the transaction so it rolls back, and +ignore superseded Web snapshots by request generation. Real PostgreSQL tests +block both sent and failed finalization on row locks, abort the caller, and +verify the ledger remains non-terminal. + +A fresh review then found a P1 constructor gap: a disabled manifest missing a +required array could throw before the Host's per-plugin isolation boundary. The +constructor now creates inert placeholders, while `initialize()` validates and +hydrates trusted manifest metadata before disabled gating. Valid disabled +plugins retain static compatibility routes for structured 503 responses; +malformed disabled plugins are reported as `enabled: false`, `degraded`, run no +lifecycle, and do not block the next healthy plugin. A second independent review +of the final working tree reported no P0/P1/P2 findings. + +## Spec-update judgment + +The merged child branches already record the durable-delivery, caller-Abort, +manifest-validation, live-refresh, timezone and composite-cursor lessons in the +backend/frontend specs. The build-order prerequisite is already present in the +Plugin API compatibility checklist. No additional repository-wide spec rule is +needed for the one-line integration expectation alignment. diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/task.json b/.trellis/tasks/08-26-pr36-final-integration-review/task.json new file mode 100644 index 0000000..9ec1b1f --- /dev/null +++ b/.trellis/tasks/08-26-pr36-final-integration-review/task.json @@ -0,0 +1,26 @@ +{ + "id": "pr36-final-integration-review", + "name": "pr36-final-integration-review", + "title": "PR #36 最终集成与 Codex 复审", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": "PR #36 review-fix integration, validation, push and Codex re-review", + "package": null, + "priority": "P1", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-26", + "completedAt": null, + "branch": "codex/plugins-integration", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From 5e3dea89dcb5f9dcd60d4e9b46efc60e21c33240 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 13:40:38 +0800 Subject: [PATCH 31/33] chore(trellis): archive PR 36 final integration --- .../check.jsonl | 0 .../design.md | 0 .../implement.jsonl | 0 .../implement.md | 4 +-- .../prd.md | 4 +-- .../research/branch-state.md | 0 .../research/verification.md | 7 ++++ .../task.json | 4 +-- .trellis/workspace/codex/index.md | 7 ++-- .trellis/workspace/codex/journal-1.md | 34 +++++++++++++++++++ 10 files changed, 51 insertions(+), 9 deletions(-) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/implement.md (84%) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/prd.md (93%) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/research/branch-state.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/research/verification.md (91%) rename .trellis/tasks/{ => archive/2026-08}/08-26-pr36-final-integration-review/task.json (91%) diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/check.jsonl b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/check.jsonl similarity index 100% rename from .trellis/tasks/08-26-pr36-final-integration-review/check.jsonl rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/check.jsonl diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/design.md b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/design.md similarity index 100% rename from .trellis/tasks/08-26-pr36-final-integration-review/design.md rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/design.md diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/implement.jsonl b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/implement.jsonl similarity index 100% rename from .trellis/tasks/08-26-pr36-final-integration-review/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/implement.jsonl diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/implement.md b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/implement.md similarity index 84% rename from .trellis/tasks/08-26-pr36-final-integration-review/implement.md rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/implement.md index 1672a92..0037bc5 100644 --- a/.trellis/tasks/08-26-pr36-final-integration-review/implement.md +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/implement.md @@ -9,6 +9,6 @@ - [x] Run focused tests, PostgreSQL integration, full test/typecheck/build and `git diff --check`. - [x] Dispatch an independent final check and resolve verified P0/P1/P2 findings. -- [ ] Commit Trellis/spec bookkeeping, archive this task, and record the session. -- [ ] Push `codex/plugins-integration`, verify PR #36 head/CI, and comment exactly +- [x] Commit Trellis/spec bookkeeping, archive this task, and record the session. +- [x] Push `codex/plugins-integration`, verify PR #36 head/CI, and comment exactly `@codex review`. diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/prd.md b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/prd.md similarity index 93% rename from .trellis/tasks/08-26-pr36-final-integration-review/prd.md rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/prd.md index 68b81c5..cb381de 100644 --- a/.trellis/tasks/08-26-pr36-final-integration-review/prd.md +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/prd.md @@ -28,8 +28,8 @@ head 重新触发 Codex Code Review。 - [x] `codex/plugins-integration` 包含 notification、Schedule 和 Inspiration 的 全部已验证修复,且 Trellis active/archive 路径无重复漂移。 - [x] 全量与定向验证通过,无未解决的本地 P0/P1/P2 审查发现。 -- [ ] 集成分支工作树干净并成功 push 到 PR #36 的远端 head。 -- [ ] PR 最新 head 已触发 CI,并已精确评论 `@codex review`。 +- [x] 集成分支工作树干净并成功 push 到 PR #36 的远端 head。 +- [x] PR 最新 head 已触发 CI,并已精确评论 `@codex review`。 - [x] 不 merge PR/main,不重写历史,不覆盖其他 worktree 的用户改动。 ## Notes diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/research/branch-state.md similarity index 100% rename from .trellis/tasks/08-26-pr36-final-integration-review/research/branch-state.md rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/research/branch-state.md diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/research/verification.md b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/research/verification.md similarity index 91% rename from .trellis/tasks/08-26-pr36-final-integration-review/research/verification.md rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/research/verification.md index 6cdc7e1..ea01194 100644 --- a/.trellis/tasks/08-26-pr36-final-integration-review/research/verification.md +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/research/verification.md @@ -48,6 +48,13 @@ malformed disabled plugins are reported as `enabled: false`, `degraded`, run no lifecycle, and do not block the next healthy plugin. A second independent review of the final working tree reported no P0/P1/P2 findings. +## PR verification + +- Pushed normally to `origin/codex/plugins-integration`; no force/rebase/main merge. +- GitHub CI `verify` passed on head `29cb75c1ec` in 1m13s. +- Posted the exact PR comment `@codex review`. +- GitHub Codex reviewed commit `29cb75c1ec` and reported no major issues. + ## Spec-update judgment The merged child branches already record the durable-delivery, caller-Abort, diff --git a/.trellis/tasks/08-26-pr36-final-integration-review/task.json b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/task.json similarity index 91% rename from .trellis/tasks/08-26-pr36-final-integration-review/task.json rename to .trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/task.json index 9ec1b1f..c145f0d 100644 --- a/.trellis/tasks/08-26-pr36-final-integration-review/task.json +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-final-integration-review/task.json @@ -3,7 +3,7 @@ "name": "pr36-final-integration-review", "title": "PR #36 最终集成与 Codex 复审", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "PR #36 review-fix integration, validation, push and Codex re-review", "package": null, @@ -11,7 +11,7 @@ "creator": "codex", "assignee": "codex", "createdAt": "2026-08-26", - "completedAt": null, + "completedAt": "2026-08-26", "branch": "codex/plugins-integration", "base_branch": "main", "worktree_path": null, diff --git a/.trellis/workspace/codex/index.md b/.trellis/workspace/codex/index.md index a877bd9..d706976 100644 --- a/.trellis/workspace/codex/index.md +++ b/.trellis/workspace/codex/index.md @@ -8,8 +8,8 @@ <!-- @@@auto:current-status --> - **Active File**: `journal-1.md` -- **Total Sessions**: 2 -- **Last Active**: 2026-08-24 +- **Total Sessions**: 3 +- **Last Active**: 2026-08-26 <!-- @@@/auto:current-status --> --- @@ -19,7 +19,7 @@ <!-- @@@auto:active-documents --> | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~82 | Active | +| `journal-1.md` | ~124 | Active | <!-- @@@/auto:active-documents --> --- @@ -29,6 +29,7 @@ <!-- @@@auto:session-history --> | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 3 | 2026-08-26 | PR 36 final integration and Codex review | `7420eec`, `29cb75c` | `codex/plugins-integration` | | 2 | 2026-08-24 | PR 36 notification and Host review fixes | `fdd22d9`, `4c5f55f` | `codex/plugin-notification-service` | | 1 | 2026-08-24 | Bundled Plugin API v1 notification service | `29fe6c3`, `3bd3f38` | `codex/plugin-notification-service` | <!-- @@@/auto:session-history --> diff --git a/.trellis/workspace/codex/journal-1.md b/.trellis/workspace/codex/journal-1.md index b77a476..3e8d424 100644 --- a/.trellis/workspace/codex/journal-1.md +++ b/.trellis/workspace/codex/journal-1.md @@ -88,3 +88,37 @@ Fixed caller AbortError propagation and queued-transport cancellation race; vali ### Next Steps - Integrate `fdd22d9` into the PR #36 branch and request Codex review on the resulting latest commit. + + +## Session 3: PR 36 final integration and Codex review + +**Date**: 2026-08-26 +**Task**: PR 36 final integration and Codex review +**Branch**: `codex/plugins-integration` + +### Summary + +Integrated notification, Schedule, and Inspiration review fixes; closed caller-abort finalization, stale live-response, and malformed disabled-manifest isolation gaps; passed full tests/typecheck/build/PostgreSQL integrations; pushed PR #36 and received a clean Codex review on 29cb75c. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `7420eec` | (see git log) | +| `29cb75c` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From b8510021c971077acf375060865156105a253866 Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 15:14:26 +0800 Subject: [PATCH 32/33] fix(inspiration): preserve paginated Flow history --- .trellis/spec/frontend/quality-guidelines.md | 7 + plugins/inspiration/web/index.js | 53 ++++--- tests/inspiration-clients.test.ts | 155 +++++++++++++++++++ 3 files changed, 196 insertions(+), 19 deletions(-) diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md index 4661972..1499548 100644 --- a/.trellis/spec/frontend/quality-guidelines.md +++ b/.trellis/spec/frontend/quality-guidelines.md @@ -37,6 +37,11 @@ Questions to answer: face rendering. - Live refresh must be change-sensitive, preserve active input state, and make late asynchronous work inert after contribution unmount. +- A contribution that extends a cursor-paginated view in memory must not let a + later first-page full/live snapshot truncate the visible window. Track the + authoritative first-page signature separately, merge refreshed DTOs without + duplicates, and preserve the expanded window's continuation cursor (including + an exhausted `null`) until the contribution is reset. --- @@ -46,6 +51,8 @@ Questions to answer: - Live contribution tests cover changed snapshots, unchanged polling, editing deferral, and unmount during an in-flight request. +- Paginated live-view tests cover action-triggered Host rebuilds, later first-page + changes, DTO replacement/order, cursor exhaustion, and fresh-mount reset. --- diff --git a/plugins/inspiration/web/index.js b/plugins/inspiration/web/index.js index 0496c22..874fd1a 100644 --- a/plugins/inspiration/web/index.js +++ b/plugins/inspiration/web/index.js @@ -270,6 +270,19 @@ function deliverySnapshotSignature(deliveries, nextCursor) { ]); } +function mergeUniqueDeliveries(...deliveryGroups) { + const merged = []; + const seen = new Set(); + for (const deliveries of deliveryGroups) { + for (const delivery of Array.isArray(deliveries) ? deliveries : []) { + if (seen.has(delivery.id)) continue; + seen.add(delivery.id); + merged.push(delivery); + } + } + return merged; +} + function isHostEditing(root) { const active = root?.ownerDocument?.activeElement; return Boolean( @@ -291,6 +304,7 @@ export async function activate({ api, refresh, root }) { let latestSettings = null; let latestDeliveries = []; let latestDeliveryNextCursor = null; + let deliveryHistoryExpanded = false; let currentCandidate = null; let mounted = true; let lifecycleVersion = 0; @@ -304,12 +318,16 @@ export async function activate({ api, refresh, root }) { flow: deliverySnapshotSignature(deliveries, nextCursor), }); - const applyLiveSnapshot = (inspirations, deliveries, nextCursor) => { + const applySnapshot = (inspirations, deliveries, nextCursor) => { latestInspirations = inspirations; - latestDeliveries = deliveries; - latestDeliveryNextCursor = nextCursor; + if (deliveryHistoryExpanded) { + latestDeliveries = mergeUniqueDeliveries(deliveries, latestDeliveries); + } else { + latestDeliveries = mergeUniqueDeliveries(deliveries); + latestDeliveryNextCursor = nextCursor; + } if (currentCandidate) { - const currentDelivery = deliveries.find( + const currentDelivery = latestDeliveries.find( (delivery) => delivery.id === currentCandidate.delivery.id ); const currentInspiration = inspirations.find( @@ -332,16 +350,17 @@ export async function activate({ api, refresh, root }) { api(`${API_PREFIX}/flow/deliveries?limit=20`), ]); if (!mounted || expectedLifecycleVersion !== lifecycleVersion) return {}; - latestInspirations = Array.isArray(list?.items) ? list.items : []; - latestSettings = settings; - latestDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : []; - latestDeliveryNextCursor = typeof ledger?.nextCursor === "string" + const nextInspirations = Array.isArray(list?.items) ? list.items : []; + const nextDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : []; + const nextDeliveryNextCursor = typeof ledger?.nextCursor === "string" ? ledger.nextCursor : null; + latestSettings = settings; + applySnapshot(nextInspirations, nextDeliveries, nextDeliveryNextCursor); const signatures = signaturesFor( - latestInspirations, - latestDeliveries, - latestDeliveryNextCursor + nextInspirations, + nextDeliveries, + nextDeliveryNextCursor ); presentedInboxSignature = signatures.inbox; presentedFlowSignature = signatures.flow; @@ -405,7 +424,7 @@ export async function activate({ api, refresh, root }) { if (snapshotChanged && canInvalidateHost && isHostEditing(root)) { return liveData; } - applyLiveSnapshot(nextInspirations, nextDeliveries, nextDeliveryNextCursor); + applySnapshot(nextInspirations, nextDeliveries, nextDeliveryNextCursor); if ( snapshotChanged && canInvalidateHost @@ -704,16 +723,11 @@ export async function activate({ api, refresh, root }) { const page = await api( `${API_PREFIX}/flow/deliveries?limit=20&cursor=${encodeURIComponent(latestDeliveryNextCursor)}` ); - const existing = new Set(latestDeliveries.map((delivery) => delivery.id)); - for (const delivery of Array.isArray(page?.deliveries) ? page.deliveries : []) { - if (!existing.has(delivery.id)) { - existing.add(delivery.id); - latestDeliveries.push(delivery); - } - } + latestDeliveries = mergeUniqueDeliveries(latestDeliveries, page?.deliveries); latestDeliveryNextCursor = typeof page?.nextCursor === "string" ? page.nextCursor : null; + deliveryHistoryExpanded = true; return { handled: true, message: "已加载更多 Flow 投递" }; } const candidateIsActionable = isActionableDelivery(currentCandidate?.delivery); @@ -791,6 +805,7 @@ export async function activate({ api, refresh, root }) { latestSettings = null; latestDeliveries = []; latestDeliveryNextCursor = null; + deliveryHistoryExpanded = false; currentCandidate = null; }, }; diff --git a/tests/inspiration-clients.test.ts b/tests/inspiration-clients.test.ts index ae6e2f0..41bb84e 100644 --- a/tests/inspiration-clients.test.ts +++ b/tests/inspiration-clients.test.ts @@ -448,6 +448,161 @@ test("Inspiration Web coalesces overlapping live polls behind one Host refresh", await contribution.unmount(); }); +test("Inspiration Web keeps expanded Flow history through Host and live refreshes", async () => { + let firstPageRevision = 1; + let liveRefreshes = 0; + const paginatedCursors: string[] = []; + const delivery = ( + id: string, + status: string, + outcome: string | null, + surfacedAt: string, + source = "scheduled" + ) => ({ id, version: 1, source, status, outcome, surfacedAt }); + const firstPage = () => { + if (firstPageRevision === 1) { + return { + deliveries: [ + delivery("delivery-a", "sent", null, "2026-08-26T10:00:00.000Z"), + delivery("delivery-b", "dispatching", null, "2026-08-26T09:00:00.000Z"), + ], + nextCursor: "page-2", + }; + } + if (firstPageRevision === 2) { + return { + deliveries: [ + delivery("delivery-new", "reserved", null, "2026-08-26T11:00:00.000Z"), + delivery("delivery-a", "sent", "viewed", "2026-08-26T10:00:00.000Z"), + ], + nextCursor: "page-2-regressed", + }; + } + return { + deliveries: [ + delivery("delivery-live", "failed", null, "2026-08-26T12:00:00.000Z", "manual"), + delivery("delivery-new", "sent", "archived", "2026-08-26T11:00:00.000Z"), + ], + nextCursor: "page-2-live-regressed", + }; + }; + const pluginApi = async (path: string) => { + if (path.includes("/inspirations?")) return { items: [], nextCursor: null }; + if (path.endsWith("/flow/settings")) return { id: "default", version: 1 }; + if (path.includes("/flow/deliveries?")) { + const cursor = new URL(path, "http://echolog.local").searchParams.get("cursor"); + if (!cursor) return firstPage(); + paginatedCursors.push(cursor); + if (cursor === "page-2") { + firstPageRevision = 2; + return { + deliveries: [ + delivery("delivery-c", "failed", null, "2026-08-26T08:00:00.000Z"), + ], + nextCursor: "page-3", + }; + } + if (cursor === "page-3") { + return { + deliveries: [ + delivery("delivery-d", "sent", "kept", "2026-08-26T07:00:00.000Z"), + ], + nextCursor: null, + }; + } + throw new Error(`Regressed pagination cursor: ${cursor}`); + } + throw new Error(`Unexpected API path: ${path}`); + }; + const host = createPluginWebHost(async (path: string) => { + assert.equal(path, "/plugins"); + return { + plugins: [{ + id: "inspiration", + enabled: true, + state: "ready", + webEntry: webModulePath, + }], + }; + }); + const data: Record<string, unknown> = {}; + const root = { ownerDocument: { activeElement: null } }; + let rendered = ""; + let refreshAndRender: () => Promise<void>; + const hostApi = { + api: pluginApi, + root, + refresh: async () => { + liveRefreshes += 1; + await refreshAndRender(); + }, + }; + refreshAndRender = async () => { + await host.refresh(hostApi); + await host.loadData(data); + rendered = String(host.renderFace( + { type: "inspiration-flow" }, + { data, esc: escapeText, escA: escapeAttribute } + )); + }; + const occurrences = (html: string, fragment: string) => html.split(fragment).length - 1; + const runAction = async () => { + const result = await host.handleAction("load-more-inspiration-deliveries", { + id: undefined, + $: () => null, + }); + if (result.refresh !== false) await refreshAndRender(); + return result; + }; + + await refreshAndRender(); + const pageTwoResult = await runAction(); + assert.equal(pageTwoResult.handled, true); + assert.equal(pageTwoResult.refresh, undefined); + assert.deepEqual(paginatedCursors, ["page-2"]); + assert.equal(occurrences(rendered, '<div class="inspiration-history-row"'), 4); + assert.equal(occurrences(rendered, "<strong>待投递</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>已展示 · 已查看</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>投递中</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>定时投递失败(未展示)</strong>"), 1); + assert.match(rendered, /load-more-inspiration-deliveries/); + + await runAction(); + assert.deepEqual(paginatedCursors, ["page-2", "page-3"]); + assert.equal(occurrences(rendered, '<div class="inspiration-history-row"'), 5); + assert.equal(occurrences(rendered, "<strong>已展示 · 已保留</strong>"), 1); + assert.doesNotMatch(rendered, /load-more-inspiration-deliveries/); + + firstPageRevision = 3; + await host.loadData(data, { live: true }); + assert.equal(liveRefreshes, 1); + assert.deepEqual(paginatedCursors, ["page-2", "page-3"]); + assert.equal(occurrences(rendered, '<div class="inspiration-history-row"'), 6); + assert.equal(occurrences(rendered, "<strong>手动投递失败(未展示)</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>已展示 · 已归档</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>待投递</strong>"), 0); + assert.equal(occurrences(rendered, "<strong>已展示 · 已查看</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>投递中</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>定时投递失败(未展示)</strong>"), 1); + assert.equal(occurrences(rendered, "<strong>已展示 · 已保留</strong>"), 1); + assert.doesNotMatch(rendered, /load-more-inspiration-deliveries/); + + await host.stop(); + await host.refresh({ api: pluginApi, root }); + const freshData: Record<string, unknown> = {}; + await host.loadData(freshData); + const freshRender = String(host.renderFace( + { type: "inspiration-flow" }, + { data: freshData, esc: escapeText, escA: escapeAttribute } + )); + assert.equal(occurrences(freshRender, '<div class="inspiration-history-row"'), 2); + assert.equal(occurrences(freshRender, "<strong>投递中</strong>"), 0); + assert.equal(occurrences(freshRender, "<strong>定时投递失败(未展示)</strong>"), 0); + assert.equal(occurrences(freshRender, "<strong>已展示 · 已保留</strong>"), 0); + assert.match(freshRender, /load-more-inspiration-deliveries/); + await host.stop(); +}); + test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow policy", async () => { const { activate } = await import(webModulePath); const malicious = '<img src=x onerror="alert(1)">'; From fe391aea4ab2bd1e1554af400787494d705f19ef Mon Sep 17 00:00:00 2001 From: sevencolor7 <465892377@qq.com> Date: Wed, 26 Aug 2026 15:28:40 +0800 Subject: [PATCH 33/33] chore(trellis): archive Flow history review fix --- .../check.jsonl | 3 ++ .../08-26-pr36-flow-history-refresh/design.md | 13 ++++++++ .../implement.jsonl | 3 ++ .../implement.md | 7 ++++ .../08-26-pr36-flow-history-refresh/prd.md | 32 ++++++++++++++++++ .../research/verification.md | 13 ++++++++ .../08-26-pr36-flow-history-refresh/task.json | 26 +++++++++++++++ .trellis/workspace/codex/index.md | 5 +-- .trellis/workspace/codex/journal-1.md | 33 +++++++++++++++++++ 9 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 .trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/design.md create mode 100644 .trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.md create mode 100644 .trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/research/verification.md create mode 100644 .trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/task.json diff --git a/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/check.jsonl b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/check.jsonl new file mode 100644 index 0000000..705e43b --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/check.jsonl @@ -0,0 +1,3 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Review pagination merge and live refresh behavior."} +{"file": "plugins/inspiration/web/index.js", "reason": "Review the exact Codex P2 implementation."} diff --git a/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/design.md b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/design.md new file mode 100644 index 0000000..7e4c6fc --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/design.md @@ -0,0 +1,13 @@ +# Design + +Treat the first-page snapshot as an authoritative refresh of the leading window, +not an instruction to discard already loaded tail pages. Track whether pagination +has expanded the delivery window. While expanded, merge first-page snapshots by +ID: use the server's newest first-page order and values, then retain older cached +rows absent from that page. Keep the pagination cursor from the expanded window; +do not regress it to the first-page cursor. A fresh activation/unmount resets the +expanded state normally. + +The merge helper is shared by full load and live load so Host refresh and polling +cannot diverge. Tests must render after the real action refresh path and after a +later live snapshot. diff --git a/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.jsonl b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.jsonl new file mode 100644 index 0000000..0fca591 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.jsonl @@ -0,0 +1,3 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Preserve snapshot, refresh, focus, and unmount invariants."} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Keep Web contribution and Host refresh contracts aligned."} diff --git a/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.md b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.md new file mode 100644 index 0000000..fccc2e0 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/implement.md @@ -0,0 +1,7 @@ +# Implementation plan + +- [x] Add a bounded first-page merge policy for expanded delivery history. +- [x] Apply it consistently in full and live snapshot paths. +- [x] Add action/refresh/render and later-live regression tests. +- [x] Run focused and full validation; dispatch independent review. +- [x] Commit, push, verify CI, request latest-head Codex review, archive task. diff --git a/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/prd.md b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/prd.md new file mode 100644 index 0000000..0672753 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/prd.md @@ -0,0 +1,32 @@ +# PR #36 Inspiration Flow history refresh fix + +## Goal + +Resolve the latest Codex P2 on PR #36 so loaded Flow delivery pages remain +visible across Host refreshes and live polling, without weakening snapshot +invalidation or unmount isolation. + +## Requirements + +- Preserve accumulated, deduplicated delivery history after “加载更多投递”. +- A subsequent full `load()` or `loadLive()` first-page snapshot must refresh + matching rows and prepend genuinely newer rows without dropping older pages. +- Preserve correct next-cursor semantics and allow an intentional fresh mount to + start from the first page. +- Keep request-generation, focus, refresh coalescing, escaping, and unmount + behavior intact. +- Add non-self-proving automated coverage for action → Host refresh → render and + live polling after pagination. +- Push only additive commits to `codex/plugins-integration`; do not merge main. + +## Acceptance Criteria + +- [x] Loaded page-two rows survive action-triggered Host refresh. +- [x] Loaded page-two rows survive later live first-page snapshots, while changed + first-page rows update correctly. +- [x] Full tests, typecheck, build, diff-check, CI, and latest-head Codex review pass. +- [x] No unresolved P0/P1/P2 findings; Trellis task is archived. + +## Notes + +- Review: https://github.com/CubePlus1/echolog/pull/36#discussion_r3859983432 diff --git a/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/research/verification.md b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/research/verification.md new file mode 100644 index 0000000..93789ed --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/research/verification.md @@ -0,0 +1,13 @@ +# Verification + +- Fix commit: `b851002` (`fix(inspiration): preserve paginated Flow history`). +- Focused Inspiration Web tests: 9/9 pass. +- `pnpm test`: 212 passed, 1 platform-dependent skip, 0 failed. +- `pnpm typecheck`: pass. +- `pnpm build`: pass. +- `git diff --check`: pass. +- Independent SOL High review: no P0/P1/P2; separate Host-driven reproduction + confirmed one refresh, retained tail rows, and exhausted-cursor preservation. +- GitHub CI passed on `b8510021c971077acf375060865156105a253866`. +- GitHub Codex reviewed `b8510021c9` and reported no major issues. +- Durable pagination/live-refresh rule added to frontend quality guidelines. diff --git a/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/task.json b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/task.json new file mode 100644 index 0000000..249ddfa --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-26-pr36-flow-history-refresh/task.json @@ -0,0 +1,26 @@ +{ + "id": "pr36-flow-history-refresh", + "name": "pr36-flow-history-refresh", + "title": "PR #36 preserve Inspiration Flow history", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-26", + "completedAt": "2026-08-26", + "branch": "codex/plugins-integration", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workspace/codex/index.md b/.trellis/workspace/codex/index.md index d706976..b87df28 100644 --- a/.trellis/workspace/codex/index.md +++ b/.trellis/workspace/codex/index.md @@ -8,7 +8,7 @@ <!-- @@@auto:current-status --> - **Active File**: `journal-1.md` -- **Total Sessions**: 3 +- **Total Sessions**: 4 - **Last Active**: 2026-08-26 <!-- @@@/auto:current-status --> @@ -19,7 +19,7 @@ <!-- @@@auto:active-documents --> | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~124 | Active | +| `journal-1.md` | ~157 | Active | <!-- @@@/auto:active-documents --> --- @@ -29,6 +29,7 @@ <!-- @@@auto:session-history --> | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 4 | 2026-08-26 | PR 36 Flow history review fix | `b851002` | `codex/plugins-integration` | | 3 | 2026-08-26 | PR 36 final integration and Codex review | `7420eec`, `29cb75c` | `codex/plugins-integration` | | 2 | 2026-08-24 | PR 36 notification and Host review fixes | `fdd22d9`, `4c5f55f` | `codex/plugin-notification-service` | | 1 | 2026-08-24 | Bundled Plugin API v1 notification service | `29fe6c3`, `3bd3f38` | `codex/plugin-notification-service` | diff --git a/.trellis/workspace/codex/journal-1.md b/.trellis/workspace/codex/journal-1.md index 3e8d424..422c9c4 100644 --- a/.trellis/workspace/codex/journal-1.md +++ b/.trellis/workspace/codex/journal-1.md @@ -122,3 +122,36 @@ Integrated notification, Schedule, and Inspiration review fixes; closed caller-a ### Next Steps - None - task complete + + +## Session 4: PR 36 Flow history review fix + +**Date**: 2026-08-26 +**Task**: PR 36 Flow history review fix +**Branch**: `codex/plugins-integration` + +### Summary + +Fixed Codex P2 by preserving expanded Inspiration Flow delivery history across Host rebuilds and live first-page refreshes, retaining authoritative signatures and expanded cursors; added Host-path regression coverage and frontend spec guidance; full validation, CI, independent review, and GitHub Codex review passed. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `b851002` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete