dsh-scheduled-message 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/client.js +1739 -0
- package/cordis.patch.yml +3 -0
- package/docs/DETAIL.md +440 -0
- package/docs/preview.png +0 -0
- package/icon.svg +13 -0
- package/index.js +694 -0
- package/lib/core.js +466 -0
- package/locale/en.json +91 -0
- package/locale/zh.json +91 -0
- package/package.json +65 -0
- package/scripts/build-client.mjs +99 -0
- package/scripts/check.mjs +101 -0
- package/scripts/host-smoke.mjs +433 -0
package/cordis.patch.yml
ADDED
package/docs/DETAIL.md
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# dsh-scheduled-message — design notes
|
|
2
|
+
|
|
3
|
+
Everything here was checked against the installed harness
|
|
4
|
+
(`DeepSeek Harness 0.2.2-rc.2`, `…/resources/app.asar/dsh/node_modules/@deepseek-ai/…`), not against
|
|
5
|
+
a remembered API. File and line references point at the shipped package sources inside that ASAR.
|
|
6
|
+
|
|
7
|
+
- [1. Scope](#1-scope)
|
|
8
|
+
- [2. Platform facts](#2-platform-facts)
|
|
9
|
+
- [3. Host design](#3-host-design)
|
|
10
|
+
- [4. Client design](#4-client-design)
|
|
11
|
+
- [5. Failure and edge behaviour](#5-failure-and-edge-behaviour)
|
|
12
|
+
- [6. Tests](#6-tests)
|
|
13
|
+
- [7. Verification](#7-verification)
|
|
14
|
+
- [8. Deviations from PLAN.md](#8-deviations-from-planmd)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<a id="1-scope"></a>
|
|
19
|
+
## 1. Scope
|
|
20
|
+
|
|
21
|
+
The feature is one thing: *this exact composer content, delivered later by the Host*.
|
|
22
|
+
|
|
23
|
+
In scope: a schedule button and dialog, a 12-hour picker, quick picks, an optional Repeat that
|
|
24
|
+
becomes an Automation task, a strip that lists and manages scheduled records, a keyboard shortcut,
|
|
25
|
+
English and Chinese copy, Host-side durable storage and delivery, and late/at-startup delivery.
|
|
26
|
+
|
|
27
|
+
Out of scope, deliberately: attachments on repeating messages (the Automation service delivers text
|
|
28
|
+
only), automatic retries, polling, and any change to the normal send path.
|
|
29
|
+
|
|
30
|
+
<a id="2-platform-facts"></a>
|
|
31
|
+
## 2. Platform facts
|
|
32
|
+
|
|
33
|
+
### 2.1 A client plugin is a package export, not a URL
|
|
34
|
+
|
|
35
|
+
The Client half is not registered by naming a file. A package declares `dsh.client` with
|
|
36
|
+
`platform: 'web'` and exports `./client`; the Host turns that declaration into a served bundle under
|
|
37
|
+
`/plugins`.
|
|
38
|
+
|
|
39
|
+
- `dsh-client-modules/src/index.ts:203` — `clientExportOf` resolves `exports["./client"]`, accepting a
|
|
40
|
+
string or one level of `default`.
|
|
41
|
+
- `dsh-client-modules/src/client/manifest.ts` — `WebBootEntry.id` is *the package name*; `inject`
|
|
42
|
+
names package rows whose factories must arrive first; `external` adds non-baseline module requests.
|
|
43
|
+
|
|
44
|
+
The bundle itself is a lazy-CJS factory. Every shipped plugin ends with the same shape
|
|
45
|
+
(`dsh-ops/lib/client.js`, tail):
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
window.__ModuleLoader__.load({ id: "<package>", factory: (require) => { … return load("client/index.js") } })
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Executing the bundle only registers the factory; all side effects, including CSS injection, run when
|
|
52
|
+
the factory materializes. `require` resolves against the frozen platform table plus the factories
|
|
53
|
+
already registered, so a plugin bundle may only ask for baseline modules. The two this bundle uses —
|
|
54
|
+
`react` and `react-dom` — are both in that table (`react-dom` is used by `dsh-config-manager`, and
|
|
55
|
+
`react-dom/client` by `dsh-claude-style`).
|
|
56
|
+
|
|
57
|
+
### 2.2 Importable Host services
|
|
58
|
+
|
|
59
|
+
- `ctx.sessionController.prompt(request, signal)`
|
|
60
|
+
`dsh-api-session-controller/src/index.ts:467`, request type `src/types.ts:324`:
|
|
61
|
+
`{ requestId, sessionId, mode: 'queue' | 'steer', content: PromptContentPart[], clientTimeZone? }`.
|
|
62
|
+
`requestId` is idempotent — the same id is never delivered twice, which is the crash-safety
|
|
63
|
+
mechanism for retries. `resolveAgent(sessionId)` answers `{ agent }` or `{ error }`.
|
|
64
|
+
- `ctx.attachments` — `dsh-attachment/src/index.ts:116` `saveImages`, `:195` `readImage`,
|
|
65
|
+
`:247` `readFileStream`; value shapes in `src/types.ts:127`/`:136`.
|
|
66
|
+
- `ctx.fileUploads` — `dsh-client-file-upload/src/index.ts:127` `uploadStream({ sessionId, data })`
|
|
67
|
+
returns `{ receiptId, file }`; `:194` `resolve(agent, receiptId)` returns the staged attachment for
|
|
68
|
+
that exact Agent.
|
|
69
|
+
- `ctx.schedule` — `dsh-schedule/src/index.ts:381` `create(request, actorSessionId?, signal?)`;
|
|
70
|
+
timing union in `src/types.ts:335`, request in `src/types.ts:478`. Title ≤120 chars, prompt
|
|
71
|
+
non-empty, both trimmed.
|
|
72
|
+
- `ctx.storageDomain` — `dsh-storage-domain/src/spec.ts:91` `domainTable`, `:107` `defineDomain`;
|
|
73
|
+
`src/index.ts:126` calls `tableSpec.valueSchema.parse(raw)` on read. The service is provided as
|
|
74
|
+
`storageDomain` (`dsh-storage-domain/lib/index.js`: `domainCtx.provide("storageDomain", facility)`),
|
|
75
|
+
and unit names must match `UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/` exported by `@deepseek-ai/dsh-storage`.
|
|
76
|
+
- `ctx.connection.fetch` — `dsh-client-connection/src/rpc.ts:153` `ConnectionFetchRoute`,
|
|
77
|
+
`:175` `register(route)`. Paths are absolute below `/api` (`dsh-client-file-upload/src/protocol.ts:2`
|
|
78
|
+
uses `/api/session/uploadFileBinary`). Only owner peers reach an exact route.
|
|
79
|
+
- `ctx.timer.timeout(callback, delay)` — `cordis-plugin-timer/src/index.ts:29`.
|
|
80
|
+
|
|
81
|
+
### 2.3 Composer slots and their props
|
|
82
|
+
|
|
83
|
+
- `conversation.input.right` — list, session scope, **no owner values**
|
|
84
|
+
(`dsh-client-ui-conversation/src/client/contract/slots.ts:327`). The composer renders it with an
|
|
85
|
+
empty owner object: `renderSlot('conversation.input.right', {})`
|
|
86
|
+
(`src/client/skeleton/InputBar.tsx:541`).
|
|
87
|
+
- `conversation.input.dock` — list, session scope, owner `InputZone` = `{ session, input }`
|
|
88
|
+
(`contract/slots.ts:307`; the interface is at `:451`). This owner share exists on the dock only.
|
|
89
|
+
- A session-scoped entry receives the standard kit (`contract/slots.ts:397`):
|
|
90
|
+
`useConversation`, `useInput`, **`inputActions`**, plus whatever `inject` returns, plus `t` when
|
|
91
|
+
`locale` is given. There is no `input` prop on `conversation.input.right`: the live draft, phase
|
|
92
|
+
and attachment ids are read with the `useInput` selector hook
|
|
93
|
+
(`const input = props.useInput(state => state)`), which is how the composer's own `InputBar`
|
|
94
|
+
reads them (`InputBar.tsx:57`).
|
|
95
|
+
- Registration form, as used by the shipped docks
|
|
96
|
+
(`src/client/queue/QueueDock.tsx:393`, `src/client/skeleton/TodoPanel.tsx:116`):
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
100
|
+
name: 'conversation.input.dock', id: 'queue', order: 20, locale: NS,
|
|
101
|
+
inject: sessionId => ({ … }),
|
|
102
|
+
}, Component))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`inject` receives the session id, so this bundle takes the conversation id from there and keeps a
|
|
106
|
+
`session.sessionId` fallback.
|
|
107
|
+
- Attachment hand-off: `dsh-client-ui-conversation/src/client/service.ts:479`
|
|
108
|
+
`serializeDraftAttachments(ids)`. It throws for a vanished id and for an upload that has not
|
|
109
|
+
finished, which the dialog reports instead of scheduling a partial attachment.
|
|
110
|
+
|
|
111
|
+
### 2.4 Shortcuts, locale, layout
|
|
112
|
+
|
|
113
|
+
- `ctx.shortcuts.register(command)` — `dsh-client-shortcuts/src/client/types.ts:137`; binding type in
|
|
114
|
+
`src/binding.ts` (`{ code, secondCode?, modifiers }`), profiles
|
|
115
|
+
`desktop|web × macos|windows|linux`. Command ids must match
|
|
116
|
+
`^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$`.
|
|
117
|
+
- `ctx.locale.register(ns, dicts)` / `ctx.locale.bind(ns)` —
|
|
118
|
+
`dsh-client-locale/src/client/index.ts:403`, `:459`. A dictionary is `Record<string, string>` with
|
|
119
|
+
`{name}` placeholders.
|
|
120
|
+
- `ctx.layout.selectPanel('schedules')` — the Automation tasks page id, exactly as
|
|
121
|
+
`dsh-client-ui-schedule/src/client/index.ts:76` defines it (`const PANEL_ID = 'schedules'`). The call
|
|
122
|
+
throws when that panel is not registered, so it is wrapped.
|
|
123
|
+
|
|
124
|
+
<a id="3-host-design"></a>
|
|
125
|
+
## 3. Host design
|
|
126
|
+
|
|
127
|
+
### 3.1 Shape
|
|
128
|
+
|
|
129
|
+
`index.js` exports `{ name, inject: ['storageDomain'], apply(ctx) }`. `apply` opens one
|
|
130
|
+
`ctx.inject(['storageDomain', 'sessionController', 'connection', 'attachments', 'fileUploads'])`
|
|
131
|
+
scope for the whole feature and a nested `ctx.inject(['schedule'])` for Repeat, so a profile without
|
|
132
|
+
the Automation service still loads and answers `repeat-unavailable` instead of failing.
|
|
133
|
+
|
|
134
|
+
`lib/core.js` holds all logic that is not I/O: 12-hour conversion, wall-clock→instant resolution per
|
|
135
|
+
IANA zone, request validation, RRULE text, timer chunking, retention, title summary. It has no
|
|
136
|
+
imports, so the same file is unit-tested and inlined into the browser bundle.
|
|
137
|
+
|
|
138
|
+
### 3.2 Durable record
|
|
139
|
+
|
|
140
|
+
Domain `dsh_scheduled_message` (storage unit names are matched against
|
|
141
|
+
`^[a-z][a-z0-9_]*$` by `@deepseek-ai/dsh-storage`, so the hyphenated package name is not a legal
|
|
142
|
+
domain name — the smoke test asserts the pattern), version 1, table `messages`, keyed by record id:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
{
|
|
146
|
+
id, sessionId, dueAt, timeZone, // dueAt is one absolute instant
|
|
147
|
+
text, images: ImageAttachmentRef[], files: FileAttachmentRef[],
|
|
148
|
+
createdAt, updatedAt,
|
|
149
|
+
status: 'pending' | 'sending' | 'sent' | 'failed' | 'cancelled',
|
|
150
|
+
sentAt?, late?: boolean, error?: string
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Rules: at most 500 `pending` records; `sent`/`cancelled` records are pruned after 7 days (at open and
|
|
155
|
+
after each delivery pass); a record left in `sending` by a crash is retried at the next start, and its
|
|
156
|
+
`requestId` (`dsm-<id>`) makes the retry idempotent.
|
|
157
|
+
|
|
158
|
+
The table's `valueSchema` is a plain object with a `parse` method rather than a zod schema: the
|
|
159
|
+
storage layer only ever calls `parse` (`dsh-storage-domain/src/index.ts:126`), so this keeps the
|
|
160
|
+
bundle installable with no runtime dependency while still validating every record that crosses the
|
|
161
|
+
durable boundary.
|
|
162
|
+
|
|
163
|
+
### 3.3 Routes
|
|
164
|
+
|
|
165
|
+
| Path (below `/api`) | Method | Body | Purpose |
|
|
166
|
+
|---|---|---|---|
|
|
167
|
+
| `/dsh-scheduled-message/create` | POST | streaming, 64 MiB cap | validate, convert attachments, store, re-arm |
|
|
168
|
+
| `/dsh-scheduled-message/list` | GET | `?sessionId=` | pending + failed + sending + sent within 24 h |
|
|
169
|
+
| `/dsh-scheduled-message/cancel` | POST | `{ id }` | pending or failed → cancelled |
|
|
170
|
+
| `/dsh-scheduled-message/reschedule` | POST | `{ id, dueAt }` | pending only; the new instant must be ≥30 s ahead |
|
|
171
|
+
| `/dsh-scheduled-message/send-now` | POST | `{ id }` | deliver now, under the delivery lock |
|
|
172
|
+
| `/dsh-scheduled-message/retry` | POST | `{ id }` | failed → pending at `now`, then deliver |
|
|
173
|
+
|
|
174
|
+
Every route answers `200` with `{ ok: true, value }` or `{ ok: false, error: { code, message } }`;
|
|
175
|
+
domain refusals are not HTTP failures, and the carrier already answers `401`/`403` before a handler
|
|
176
|
+
runs. Codes: `invalid-request`, `too-soon`, `empty`, `invalid-due-at`, `invalid-time-zone`,
|
|
177
|
+
`invalid-repeat`, `repeat-with-attachments`, `too-many-images`, `invalid-images`, `invalid-files`,
|
|
178
|
+
`text-too-long`, `invalid-session`, `unknown-session`, `unknown-receipt`, `repeat-unavailable`,
|
|
179
|
+
`too-many-pending`, `not-pending`, `not-found`, `internal`.
|
|
180
|
+
|
|
181
|
+
### 3.4 Create flow
|
|
182
|
+
|
|
183
|
+
1. **Validate** (`validateCreateRequest`, pure, clock injected): instant at least 30 s ahead, an
|
|
184
|
+
absolute ISO instant, a real IANA zone, text ≤200 000 chars, ≤10 images, not empty, and no
|
|
185
|
+
attachments when Repeat is on.
|
|
186
|
+
2. **Resolve the conversation** with `sessionController.resolveAgent`. A missing conversation is
|
|
187
|
+
refused before anything is stored.
|
|
188
|
+
3. **Repeat branch**: `ctx.schedule.create({ title: summariseTitle(text), prompt: text,
|
|
189
|
+
timing: { kind: 'rrule', rrule }, target: { kind: 'session', sessionId } })` and answer
|
|
190
|
+
`{ kind: 'automation', taskId, rrule }`. No scheduled-message record is written.
|
|
191
|
+
4. **One-shot branch**: `saveImages` for the browser-encoded images, `fileUploads.resolve` for each
|
|
192
|
+
staged file receipt, then one `pending` record and a re-armed timer.
|
|
193
|
+
|
|
194
|
+
### 3.5 Timer
|
|
195
|
+
|
|
196
|
+
One `ctx.timer.timeout`, armed for the earliest `pending` (or crash-left `sending`) record:
|
|
197
|
+
|
|
198
|
+
- delay = `min(dueAt − now, 2^31 − 1)`, so a wait longer than `setTimeout` can hold fires early and
|
|
199
|
+
re-arms;
|
|
200
|
+
- re-armed after open, after every create/cancel/reschedule/retry, and after every delivery pass;
|
|
201
|
+
- an arm sequence number makes a slower, older re-arm a no-op;
|
|
202
|
+
- no polling, no interval.
|
|
203
|
+
|
|
204
|
+
### 3.6 Delivery
|
|
205
|
+
|
|
206
|
+
Delivery is serialized through one promise chain, so two due records never interleave:
|
|
207
|
+
|
|
208
|
+
1. mark `sending` (durably) before any I/O;
|
|
209
|
+
2. rebuild the content: `{ type: 'text' }`, then one re-read image per reference
|
|
210
|
+
(`readImage` → base64), then one re-staged file per reference
|
|
211
|
+
(`uploadStream({ sessionId, data: readFileStream(ref) })` → `{ type: 'file', receiptId }`);
|
|
212
|
+
3. `sessionController.prompt({ requestId: 'dsm-<id>', mode: 'queue', content, clientTimeZone })`
|
|
213
|
+
under a 120 s `AbortSignal.timeout`;
|
|
214
|
+
4. success → `sent` with `sentAt` and `late` (true when it settled more than 60 s after `dueAt`);
|
|
215
|
+
failure → `failed` with a ≤500-character reason. Never auto-retried;
|
|
216
|
+
5. prune, then re-arm.
|
|
217
|
+
|
|
218
|
+
`mode: 'queue'` is what Enter does while an agent is running, so a busy conversation is not an error.
|
|
219
|
+
|
|
220
|
+
### 3.7 Lifecycle
|
|
221
|
+
|
|
222
|
+
One `ctx.effect` owns the timer and the domain handle: disabling the plugin stops the timer, removes
|
|
223
|
+
the routes, closes the domain and leaves every record on disk. Re-enabling re-arms from storage.
|
|
224
|
+
|
|
225
|
+
<a id="4-client-design"></a>
|
|
226
|
+
## 4. Client design
|
|
227
|
+
|
|
228
|
+
### 4.1 Bundle
|
|
229
|
+
|
|
230
|
+
`client.js` is generated by `scripts/build-client.mjs` from three sources: `client.src.js` (the
|
|
231
|
+
factory body), `lib/core.js` (inlined as a closure-scoped `core`) and `locale/*.json` (inlined as
|
|
232
|
+
`EN`/`ZH`). The artifact is committed, and both a unit test and `npm run check` fail when it is
|
|
233
|
+
stale, so the shared date logic really is one implementation instead of two.
|
|
234
|
+
|
|
235
|
+
The factory returns `{ name, inject: ['slots', 'locale'], apply }`. Everything registered in `apply`
|
|
236
|
+
is owned by `ctx.effect`, so a disabled plugin removes its styles, slots and shortcut.
|
|
237
|
+
|
|
238
|
+
### 4.2 Button and dialog
|
|
239
|
+
|
|
240
|
+
`ScheduleButton` registers into `conversation.input.right` (order 900). It reads the draft, the
|
|
241
|
+
attachment ids and the phase through the standard `useInput` selector hook, is disabled when there
|
|
242
|
+
is nothing to send or the composer is busy, and keeps the editor focus by cancelling `mousedown`.
|
|
243
|
+
A seat mounted without the hook (no composer context) falls back to an inert snapshot instead of
|
|
244
|
+
throwing.
|
|
245
|
+
|
|
246
|
+
The dialog is a `ReactDOM.createPortal` into `document.body` — the composer card clips fixed
|
|
247
|
+
overlays, so a portal is required, not cosmetic. It holds:
|
|
248
|
+
|
|
249
|
+
- header, and a message preview (first two lines plus an attachment count);
|
|
250
|
+
- quick picks: *In 1 hour*, *This evening 8:00 PM* (hidden once past), *Tomorrow 9:00 AM*,
|
|
251
|
+
*Next Monday 9:00 AM*;
|
|
252
|
+
- `<input type="date">` with `min` = today in the browser zone, hour 1–12, minute in 5-minute steps,
|
|
253
|
+
and an **AM / PM** segmented control;
|
|
254
|
+
- a summary line, red when the selection is in the past or under 30 s away, and red for a wall clock
|
|
255
|
+
a DST gap removed (that case is refused rather than shifted);
|
|
256
|
+
- a **Repeat** checkbox with the frequency and a rule-specific note; the day-29–31 case notes that
|
|
257
|
+
shorter months are skipped;
|
|
258
|
+
- footer with Cancel and the primary action, which becomes **Create automation** when Repeat is on.
|
|
259
|
+
|
|
260
|
+
Escape, the backdrop and the header button close it; Tab is trapped; focus returns to whatever was
|
|
261
|
+
focused before it opened. Submitting converts the selection with `core.wallClockToInstant` in the
|
|
262
|
+
browser's zone and sends one absolute instant, so the Host never has to guess a zone.
|
|
263
|
+
|
|
264
|
+
Success clears the draft and its attachments with `inputActions.setDraft('')` /
|
|
265
|
+
`removeAttachment(id)`, shows a notice, refreshes the strip, and for Repeat opens the Automation task
|
|
266
|
+
page. Failure keeps the draft, keeps the dialog open and shows the reason inline.
|
|
267
|
+
|
|
268
|
+
### 4.3 Shortcut
|
|
269
|
+
|
|
270
|
+
`dsh-scheduled-message.open`, label "Schedule message", aliases `schedule`/`later`/`deliver`,
|
|
271
|
+
`{ code: 'Enter', modifiers: ['alt'] }` as the default on all six profiles, `regions: ['editable']`,
|
|
272
|
+
`modals: []`. DSH's own Enter handler swallows Alt+Enter without a line break and without submitting
|
|
273
|
+
(`ui-conversation/src/client/input/editor/keymap.ts`), so the chord was free.
|
|
274
|
+
|
|
275
|
+
`resolve()` returns `pass` outside an editor, `blocked` with a localized reason when no composer is
|
|
276
|
+
mounted or when the draft is empty or mid-send, and otherwise `handled` with the mounted composer's
|
|
277
|
+
opener. Mounted composers register themselves while mounted; the last one that received text wins, so
|
|
278
|
+
a split view picks the conversation the user was last typing in.
|
|
279
|
+
|
|
280
|
+
### 4.4 Strip
|
|
281
|
+
|
|
282
|
+
`conversation.input.dock` (order 30). Collapsed it shows the count and the next due time; expanded it
|
|
283
|
+
lists rows with the preview, attachment icons, the time, and **Send now** / **Change time** /
|
|
284
|
+
**Cancel** (cancel asks once inline). Failed rows show the stored reason and **Retry**; rows sent in
|
|
285
|
+
the last 24 h are dimmed, with a tick and "sent late" when applicable. It refreshes on mount, after
|
|
286
|
+
each action, on window `focus`, and once about 2 s after the next due instant. No polling.
|
|
287
|
+
|
|
288
|
+
### 4.5 Styling and copy
|
|
289
|
+
|
|
290
|
+
All classes are prefixed `dsm-`; surfaces and text use `--dsw-alias-bg-*`, `--dsw-alias-border-*`,
|
|
291
|
+
`--dsw-alias-label-*` and `--dsw-alias-state-*`, so light and dark both work. `#F97316` (hover
|
|
292
|
+
`#EA6A0C`) is the only literal colour. Under 640 px the dialog becomes a bottom sheet with
|
|
293
|
+
`padding-bottom: max(16px, env(safe-area-inset-bottom))`, 44 px targets and 16 px inputs;
|
|
294
|
+
`prefers-reduced-motion: reduce` removes the sheet and button transitions. Every visible string goes
|
|
295
|
+
through the `dsh-scheduled-message` namespace, and times are formatted with
|
|
296
|
+
`Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone })`.
|
|
297
|
+
|
|
298
|
+
<a id="5-failure-and-edge-behaviour"></a>
|
|
299
|
+
## 5. Failure and edge behaviour
|
|
300
|
+
|
|
301
|
+
| Case | Behaviour |
|
|
302
|
+
|---|---|
|
|
303
|
+
| Composer busy (`phase !== 'plain'`) | Button disabled; shortcut reports "Wait for the message to finish sending". |
|
|
304
|
+
| Empty draft | Button disabled; shortcut reports "Type a message first". |
|
|
305
|
+
| File still uploading | `serializeDraftAttachments` throws → "Wait for uploads to finish"; draft kept. |
|
|
306
|
+
| Attachment vanished | Same path, distinct message; nothing is scheduled. |
|
|
307
|
+
| Agent running at due time | Delivered with `mode: 'queue'`, exactly like Enter. |
|
|
308
|
+
| Conversation archived or deleted | `resolveAgent`/`prompt` fails → `failed` with the reason; Cancel still works. |
|
|
309
|
+
| Model rejects images | `prompt` fails → `failed` with the host diagnostic. |
|
|
310
|
+
| Two tabs act on one record | The Host serializes; the stale call gets `not-pending` and the UI refreshes. |
|
|
311
|
+
| Browser and Host in different zones | Only one absolute instant is stored; the zone is kept for display and for `DTSTART`. |
|
|
312
|
+
| DST gap in the picker | The selection is refused with a red summary instead of being silently shifted. |
|
|
313
|
+
| DST/Host zone change | One-shots are unaffected; RRULE keeps wall-clock time through `DTSTART;TZID`. |
|
|
314
|
+
| Monthly repeat on the 29th–31st | `BYMONTHDAY`, with a note that shorter months are skipped. |
|
|
315
|
+
| Repeat with attachments | Blocked in the UI and in the Host validator. |
|
|
316
|
+
| DSH closed over a due time | Delivered at the next start, `late: true`. |
|
|
317
|
+
| Crash mid-delivery | The record stays `sending`; the next start retries it; `requestId` prevents a duplicate. |
|
|
318
|
+
| Plugin disabled | Timer and routes stop; records stay; enabling resumes. |
|
|
319
|
+
|
|
320
|
+
<a id="6-tests"></a>
|
|
321
|
+
## 6. Tests
|
|
322
|
+
|
|
323
|
+
`npm test` (33 tests):
|
|
324
|
+
|
|
325
|
+
- `test/core.test.mjs` — 26 tests. 12↔24-hour conversion including 12 AM/PM, a fixed-zone instant
|
|
326
|
+
(09:00 in `Asia/Hong_Kong` → `01:00Z`), a round-trip across five zones and three dates, a DST gap
|
|
327
|
+
refused, an ambiguous hour resolved, every validator rejection (past, <30 s, empty, >10 images,
|
|
328
|
+
repeat with attachments, oversized text, bad zone, bad instant), RRULE text for all four
|
|
329
|
+
frequencies including `DTSTART;TZID=`, `WKST`, `BYMONTHDAY`, nearest-due selection, timer chunking
|
|
330
|
+
past 2^31−1, retention, the storage-unit-name pattern and the 120-character title summary.
|
|
331
|
+
- `test/client.test.mjs` — 6 tests. The committed bundle equals a fresh build; both dictionaries
|
|
332
|
+
declare one key set; executing `client.js` through a `window.__ModuleLoader__` facade yields the
|
|
333
|
+
documented plugin; `apply` registers exactly the button (`conversation.input.right`, order 900) and
|
|
334
|
+
the strip (`conversation.input.dock`, order 30) and one shortcut with Alt+Enter on all six
|
|
335
|
+
profiles, `pass` outside an editor and a `blocked` reason inside one with no composer; the
|
|
336
|
+
registered button mounts with a fake input machine and is clickable exactly when `useInput`
|
|
337
|
+
reports text or an attachment and the phase is `plain`; the bundle requires nothing outside the
|
|
338
|
+
baseline table.
|
|
339
|
+
- `test/host.test.mjs` → `scripts/host-smoke.mjs` — 66 assertions against in-memory services:
|
|
340
|
+
the six routes, six refusals that write nothing, a text create/list/deliver round trip with the
|
|
341
|
+
derived request id and queued mode, a failed delivery plus retry with the same id, send-now,
|
|
342
|
+
cancel and `not-pending`/`not-found`, reschedule and the 30 s guard, crash recovery from
|
|
343
|
+
`sending`, an image and a file surviving to delivery, Repeat producing an Automation task with the
|
|
344
|
+
expected title/target/RRULE and no local record, and a profile without the Automation service
|
|
345
|
+
refusing Repeat while one-shots still work.
|
|
346
|
+
|
|
347
|
+
`npm run check` re-checks the bundle freshness, parses all four JS files, verifies the manifest
|
|
348
|
+
exports, the dictionary parity, that every `t('…')` key exists in both dictionaries, that promised
|
|
349
|
+
files exist, and that `lib/core.js` stays dependency-free.
|
|
350
|
+
|
|
351
|
+
<a id="7-verification"></a>
|
|
352
|
+
## 7. Verification
|
|
353
|
+
|
|
354
|
+
`npm test` reports **32 tests passing** (26 core, 5 client, 1 Host wrapper that runs 66 assertions),
|
|
355
|
+
and `npm run check` passes.
|
|
356
|
+
|
|
357
|
+
### 7.1 Automated
|
|
358
|
+
|
|
359
|
+
- `node --check` on all four JavaScript files; both JSON dictionaries and the patch YAML parse.
|
|
360
|
+
- The bundle-freshness gate, the dictionary-parity gate and the `t('…')` key-coverage gate.
|
|
361
|
+
- The Host end-to-end smoke test (66 assertions) described in [§6](#6-tests).
|
|
362
|
+
|
|
363
|
+
### 7.2 Live, on an isolated DSH 0.2.2-rc.2 instance
|
|
364
|
+
|
|
365
|
+
A throwaway `web` profile was materialised under a temporary `DSH_HOME` (never the running profile,
|
|
366
|
+
never the user's data) and booted with the CLI:
|
|
367
|
+
|
|
368
|
+
```sh
|
|
369
|
+
DSH_HOME=<tmp> dsh --profile web --dump-config # composition
|
|
370
|
+
DSH_HOME=<tmp> dsh --profile web --no-open --port 0 # real boot
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Observed, in order:
|
|
374
|
+
|
|
375
|
+
1. `--dump-config` printed the `dsh-scheduled-message` layer and its inserted row, so the package was
|
|
376
|
+
resolution-visible and `dsh.bundle.patch` composed without error.
|
|
377
|
+
2. The booted instance's `window.__DSH_BOOT__` contained
|
|
378
|
+
`plugins/??dsh-scheduled-message/client.js&rev=3921005a625d`, which only happens when the Host
|
|
379
|
+
entry activated and the module scanner resolved `exports["./client"]`.
|
|
380
|
+
3. `GET /plugins/??dsh-scheduled-message/client.js&rev=…` answered `200` with this bundle (71 956
|
|
381
|
+
bytes).
|
|
382
|
+
4. `GET /api/dsh-scheduled-message/list?sessionId=…` answered `{"ok":true,"value":{"items":[]…}}`.
|
|
383
|
+
5. `POST /api/dsh-scheduled-message/create` with a real session answered
|
|
384
|
+
`{"ok":true,"value":{"kind":"once",…,"status":"pending"}}`, and the real JSON storage backend wrote
|
|
385
|
+
`storages/dsh_scheduled_message.json` with a record in the expected shape.
|
|
386
|
+
6. The real timer fired at the due instant: due `05:42:41.945Z`, sent `05:42:41.955Z`, `late:false`.
|
|
387
|
+
The delivered text became that session's title, so the prompt really entered the conversation
|
|
388
|
+
rather than only updating the record.
|
|
389
|
+
7. `POST create` with `repeat: "weekdays"` answered
|
|
390
|
+
`{"kind":"automation","taskId":"schedule-…","rrule":"DTSTART;TZID=Asia/Hong_Kong:…T134500\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;WKST=MO"}`,
|
|
391
|
+
so the real `RRule.parse` accepted the generated rule and a task was stored.
|
|
392
|
+
8. Startup delivery: a message due `05:44:05.839Z` while the Host was stopped was delivered at
|
|
393
|
+
`05:44:35.698Z` on the next start; a second cycle with the Host down for over a minute (due
|
|
394
|
+
`05:46:17.931Z`, sent `05:48:22.534Z`) recorded `late: true`.
|
|
395
|
+
|
|
396
|
+
That run found two real defects, both fixed and now covered by tests:
|
|
397
|
+
|
|
398
|
+
- the Host reached `ctx.timer` without declaring it, which cordis refuses
|
|
399
|
+
(`cannot get property "timer" without inject`); `timer` is now in the inject list, and the Client
|
|
400
|
+
half likewise takes `sessions` and `layout` from their own optional scopes instead of reading them
|
|
401
|
+
off `ctx`;
|
|
402
|
+
- the storage domain was named `dsh-scheduled-message`, which `@deepseek-ai/dsh-storage`'s
|
|
403
|
+
`UNIT_NAME_RE` (`/^[a-z][a-z0-9_]*$/`) rejects; the domain is now `dsh_scheduled_message` and
|
|
404
|
+
`test/core.test.mjs` asserts the pattern.
|
|
405
|
+
|
|
406
|
+
`rearm()` was also made non-throwing: a re-arm failure must not fail a create whose record is already
|
|
407
|
+
durable.
|
|
408
|
+
|
|
409
|
+
A third defect came from the running app after 0.1.0 shipped: the composer button rendered but never
|
|
410
|
+
became clickable, even with a message typed. The cause was the contract misreading in §8 — the seat
|
|
411
|
+
read `props.input`, which `conversation.input.right` never passes, so `draft` stayed `''` and
|
|
412
|
+
`disabled` stayed `true`. 0.1.1 reads the state with `props.useInput(state => state)` and mounts the
|
|
413
|
+
registered component in `test/client.test.mjs` to assert the enabled/disabled and tooltip outcome for
|
|
414
|
+
typed, empty, attachment-only and sending states. The live dialog, strip and shortcut are still not
|
|
415
|
+
observed (§7.3).
|
|
416
|
+
|
|
417
|
+
### 7.3 Not verified
|
|
418
|
+
|
|
419
|
+
1. **The dialog has never been rendered in a browser.** No screenshot, no focus-trap check, no
|
|
420
|
+
light/dark visual check, no 375 px sheet check. The layout is CSS-only reasoning.
|
|
421
|
+
2. **Alt+Enter has not been pressed in a real editor**, and the strip has not been seen expanded.
|
|
422
|
+
Their registrations are asserted structurally, and the shortcut's `resolve()` paths are unit
|
|
423
|
+
tested, but no real keystroke was delivered.
|
|
424
|
+
3. **The notice path** (`conversation.input.for(scope).notify(...)`) and
|
|
425
|
+
`layout.selectPanel('schedules')` were never observed: both run only from browser interactions.
|
|
426
|
+
4. **A message with images or files has not been delivered by a real Host.** The re-read, re-upload
|
|
427
|
+
and re-stage paths are covered by the smoke test's stand-ins only.
|
|
428
|
+
5. **A physical phone** was never used.
|
|
429
|
+
|
|
430
|
+
<a id="8-deviations-from-planmd"></a>
|
|
431
|
+
## 8. Deviations from PLAN.md
|
|
432
|
+
|
|
433
|
+
| Plan | Here | Why |
|
|
434
|
+
|---|---|---|
|
|
435
|
+
| `client.js` hand-written in one file | `client.src.js` + `scripts/build-client.mjs` → `client.js` | The bundle is lazy-CJS and cannot import `lib/core.js` or read JSON. Generating it keeps one implementation of the date/clock/RRULE logic, with a test that fails on drift. |
|
|
436
|
+
| `dependencies: { zod: 4.6.6 }` | no dependency; the table's `valueSchema` is a plain `{ parse }` | The storage layer only calls `parse` (`dsh-storage-domain/src/index.ts:126`), and the package then installs with nothing to resolve. |
|
|
437
|
+
| Button reads `useInput(s => s.draft)` | reads `props.useInput(state => state)` | 0.1.0 read `props.input`, which `conversation.input.right` never passes — its `renderSlot` owner is `{}` (`InputBar.tsx:541`) and only `conversation.input.dock` carries `InputZone`. That left the button permanently disabled; 0.1.1 restores the documented hook. |
|
|
438
|
+
| Six routes with mixed HTTP status codes | all six answer `200` and carry `{ ok: false, error }` | Domain refusals are not transport failures, and the carrier owns `401`/`403`. |
|
|
439
|
+
| `test/core.test.mjs` only | `core`, `client` and `host` suites plus `scripts/host-smoke.mjs` | The Host half had no execution coverage otherwise, and the generated bundle needed a freshness gate. |
|
|
440
|
+
| `list` returns "pending + failed + sent in the last 24 h" | same, plus `sending` | A record mid-delivery should stay visible in the strip instead of vanishing for a moment. |
|
package/docs/preview.png
ADDED
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Schedule message">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="dsm-fill" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#FB923C"/>
|
|
5
|
+
<stop offset="1" stop-color="#EA6A0C"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#dsm-fill)"/>
|
|
9
|
+
<circle cx="27" cy="33" r="14" fill="none" stroke="#FFFFFF" stroke-width="3.4"/>
|
|
10
|
+
<path d="M27 24.5V33l6.4 4" fill="none" stroke="#FFFFFF" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
<path d="M43 36.5 56 42l-13 5.5 1.4-5.5Z" fill="#FFFFFF"/>
|
|
12
|
+
<path d="M44.4 42h-6.2" fill="none" stroke="#FFFFFF" stroke-width="3.4" stroke-linecap="round"/>
|
|
13
|
+
</svg>
|