Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
25cafc4
docs(schedule): plan bundled plugin
CubePlus1 Aug 23, 2026
29fe6c3
feat(plugins): add permission-gated notification service
CubePlus1 Aug 23, 2026
3bd3f38
chore(trellis): archive plugin notification service
CubePlus1 Aug 23, 2026
a59d625
chore(trellis): record plugin notification session
CubePlus1 Aug 23, 2026
6dcfbc0
docs(trellis): detail plugin notification session
CubePlus1 Aug 23, 2026
3ab8946
feat(inspiration): add bundled capture and flow plugin
CubePlus1 Aug 23, 2026
9df7561
docs(inspiration): finalize task tracking
CubePlus1 Aug 23, 2026
6e42f20
chore(task): archive inspiration plugin tasks
CubePlus1 Aug 23, 2026
e2becaa
feat(schedule): add bundled schedule plugin
CubePlus1 Aug 23, 2026
bf6bbc7
chore(trellis): archive schedule tasks
CubePlus1 Aug 23, 2026
8ff6581
Revert "chore(task): archive inspiration plugin tasks"
CubePlus1 Aug 23, 2026
8484b48
feat(plugins): add permission-gated notification service
CubePlus1 Aug 23, 2026
ae65748
fix(schedule): resolve local review findings
CubePlus1 Aug 23, 2026
592bdb0
fix(inspiration): align notification service contract
CubePlus1 Aug 23, 2026
fa3a507
chore(trellis): archive schedule review fixes
CubePlus1 Aug 23, 2026
5fb4bef
chore(task): rearchive inspiration after notification fix
CubePlus1 Aug 23, 2026
c21a40e
fix(task): flatten inspiration child archives
CubePlus1 Aug 23, 2026
0dc22d0
merge: integrate plugin notification service
CubePlus1 Aug 23, 2026
6bb0d96
merge: integrate inspiration plugin
CubePlus1 Aug 23, 2026
6e0b4f4
merge: integrate schedule plugin
CubePlus1 Aug 23, 2026
b751338
fix(integration): compose plugin CLI surfaces
CubePlus1 Aug 23, 2026
d384adb
ci: run PostgreSQL integration tests
CubePlus1 Aug 23, 2026
fdd22d9
fix(plugins): preserve abort and manifest validation semantics
CubePlus1 Aug 23, 2026
4c5f55f
chore(trellis): archive notification review fixes
CubePlus1 Aug 23, 2026
c0712ae
chore(trellis): record notification review fixes
CubePlus1 Aug 23, 2026
3bc3c32
merge: address notification review findings
CubePlus1 Aug 23, 2026
c4bc686
fix(schedule): retain claims after job abort
CubePlus1 Aug 23, 2026
ea4119d
chore(trellis): archive schedule abort fix
CubePlus1 Aug 23, 2026
fde7b17
merge: address schedule review findings
CubePlus1 Aug 23, 2026
f91b8c7
fix(schedule): guard reminder claims after abort
CubePlus1 Aug 26, 2026
058c1ba
fix(schedule): refresh live views and localize reminders
CubePlus1 Aug 26, 2026
742059b
merge: integrate final schedule review fixes
CubePlus1 Aug 26, 2026
19f8979
fix(inspiration): harden Flow delivery and live clients
CubePlus1 Aug 26, 2026
ac6fdb8
test(inspiration): align recovery diagnostics
CubePlus1 Aug 26, 2026
7420eec
fix(plugins): close finalization and host races
CubePlus1 Aug 26, 2026
29cb75c
docs(trellis): record PR 36 final review
CubePlus1 Aug 26, 2026
5e3dea8
chore(trellis): archive PR 36 final integration
CubePlus1 Aug 26, 2026
b851002
fix(inspiration): preserve paginated Flow history
CubePlus1 Aug 26, 2026
fe391ae
chore(trellis): archive Flow history review fix
CubePlus1 Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
132 changes: 132 additions & 0 deletions .trellis/spec/backend/database-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,138 @@ 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.
- 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
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.
- 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`.
- 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

| 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/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

- 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.
- 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

- 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 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.
- 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

#### 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;

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 双写,跑起来才发现列不存在
Expand Down
1 change: 1 addition & 0 deletions .trellis/spec/backend/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
69 changes: 69 additions & 0 deletions .trellis/spec/backend/plugin-api-guidelines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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. 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

`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.

`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
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. 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, 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`.
15 changes: 15 additions & 0 deletions .trellis/spec/backend/quality-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,24 @@

- `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。
- 对可能等待数据库锁的后台操作,caller abort 与内部 transport timeout 必须使用
不同的 `AbortController`/错误类型;超时竞态必须同时拒绝外层等待、释放 timer 和
listener,并在锁返回后、写入前复查内部 signal,禁止迟到 continuation 落库。
- 崩溃容忍:片段开启即 INSERT,周期 UPDATE(60s),`stopTracker` 收尾在 `lastSeenAt` 而非 `new Date()`
- 采样断档检测(`now - lastSampleAt > 3×间隔`)兜住睡眠/合盖,在最后活跃时刻收尾

### 持久化插件投递任务

- 时间 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 后,迟到的事务能回滚而不是继续写入。

### 结构化诊断端点

- doctor 类端点失败可返回 503,但响应仍须包含顶层 `error` 以及逐项 diagnostics。CLI 必须保留原始 JSON 错误体,人类模式必须展示逐项检查,两种模式都以非零退出。
Expand Down
11 changes: 11 additions & 0 deletions .trellis/spec/frontend/directory-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ web/
## 关键约束

- 重建书页会销毁输入框——轮询重排前必须 `isEditing()` 检查
- 带服务端数据或时间派生状态的插件 face 必须实现 `loadLive()`;用稳定快照仅在
数据、参考窗口或派生展示状态变化时请求 Host refresh。相同轮询不得重建,
并发刷新须合并,`unmount()` 后的迟到响应不得再刷新或重新挂载资源。
- 翻页手势(wheel/drag)须跳过 `INTERACTIVE` 选择器内的目标
- 重建时加 `.no-anim` 双 rAF 移除,避免翻页动画闪烁
- Chrome 对 `preserve-3d` 翻转背面页的按钮命中不可靠;左页按钮由 `#leftPageHitProxy` 平面透明层接收并按顺序转发给当前 `.leaf.back` 的真实按钮。新增左页按钮时须保持渲染顺序一致,代理层不得保留重复 `id` 或进入键盘焦点序列。
- 插件可能把同一实体同时渲染到 overview/day 等多个 face,而宿主 `$`
是全局 `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 的值、错误只落在该 face,且 API URL 只编码真实实体 id 一次。
16 changes: 14 additions & 2 deletions .trellis/spec/frontend/quality-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,27 @@ 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.
- 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.

---

## Testing Requirements

<!-- 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.
- Paginated live-view tests cover action-triggered Host rebuilds, later first-page
changes, DTO replacement/order, cursor exhaustion, and fresh-mount reset.

---

Expand Down
2 changes: 2 additions & 0 deletions .trellis/spec/guides/cross-layer-thinking-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}
35 changes: 35 additions & 0 deletions .trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"}
Loading
Loading