dsh-cindy-host 0.1.26 → 0.1.27
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/CHANGELOG.md +10 -0
- package/README.md +15 -14
- package/package.json +4 -4
- package/src/cindy-channels.js +63 -5
- package/src/dsh-input-delivery.js +86 -0
- package/src/dsh-plugin.js +20 -1
- package/src/host-attachments.js +2 -1
- package/src/host-frame-budget.js +12 -1
- package/src/host-history-locate.js +39 -0
- package/src/host-input-delivery-store.js +57 -0
- package/src/host-input-delivery.js +158 -0
- package/src/host-version.js +4 -4
- package/src/host.js +68 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.27 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
- Implements current input-delivery v1 in Host: transactional complete-payload ownership before ACK, receipts, cancellation seals, authoritative clear boundaries, immutable logical identity and recoverable edits.
|
|
6
|
+
- Adds real persistent around/around-client-id history with content bounds and confirmation-anchor-preserving frame degradation.
|
|
7
|
+
- Uses public DSH flush and storage read checkpoints; a live claim/admission gap never authorizes replay. Verified dead-process/cold-owned-queue recovery is distinct from same-process reload.
|
|
8
|
+
- Captures attachment bytes before handoff; no temporary file receipt or staging URL is treated as durable ownership.
|
|
9
|
+
- Requires the verified DSH 0.2.1-rc.3 carrier and Node >=22.16; exact DSH peers prevent mixed-prerelease dependency graphs. Unsupported optional operations remain explicit refusals.
|
|
10
|
+
- Retains editable names, desktop string queue content, production model/active-session features and optional keytar.
|
|
11
|
+
- Upstream mobile outbox execution tests cover FIFO, lost ACK, cancellation and history-cache completion. Live handset acceptance remains a separate gate.
|
|
12
|
+
|
|
3
13
|
## 0.1.26 — 2026-10-28
|
|
4
14
|
|
|
5
15
|
Host-only patch based on 0.1.25; retains its DSH 0.2 adapter, model catalog, active-session listing, optional keytar and bundle identity.
|
package/README.md
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
# DSH Cindy Host
|
|
2
2
|
|
|
3
|
-
A Host-side adapter connecting Cindy
|
|
3
|
+
A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Requirements and installation
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Version 0.1.27 targets the verified **DSH 0.2.1-rc.3** service contracts, with **Node.js >=22.16**. Its DSH peer versions are pinned to this carrier to prevent an installer from mixing a newer prerelease SDK into the running host. The verified Desktop runtime includes Node.js 24.21. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
dsh plugin --profile
|
|
10
|
+
dsh plugin --profile <profile> add dsh-cindy-host@0.1.27
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Reload the Host plugin
|
|
13
|
+
Reload the Host plugin after installation. A full DSH restart is separate. Keep existing Cindy sign-in, device identity and settings; do not run two Host processes with the same identity.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
## 0.1.27: current durable input delivery
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
- Implements input-delivery v1, not a legacy history-only fallback. Complete text and attachment bytes are held in a private, FULL-synchronous SQLite ledger **before enqueue acknowledgment**.
|
|
18
|
+
- Provides per-clientId pending/accepted/removed/unknown receipts and real chronological around/around-client-id history. Accepted means a real user event was flushed and verified through public persistent storage, not an empty inbox or an in-memory event.
|
|
19
|
+
- Serializes delivery and mutation, persists cancellation fences and clear boundaries, and keeps edited payloads recoverable. Existing editable Host names and the plain-text desktop queue fix remain.
|
|
20
|
+
- Lost acknowledgments and same-process reloads cannot blindly replay an uncertain admission. A verified dead-process recovery may rebuild missing input; cold inbox wake-up requires verified ownership and preserved FIFO. Mixed/uncertain recovery remains pending with diagnostics rather than fabricating success or duplicate input.
|
|
21
|
+
- The latest upstream mobile outbox implementation is exercised against Host RPC and SQLite, including same-session FIFO, ACK loss, cancellation, and its mandatory history-cache gate. This is not a claim of live handset acceptance.
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
- Queue UI content is plain text as expected by current Cindy rendering.
|
|
21
|
-
- Recent durable-consumption evidence prevents late acknowledgments or stale snapshots from restoring already-consumed messages to pending/steering. This bounded record is per runtime, not a persistent message store.
|
|
22
|
-
- Host status reports pluginVersion separately from its Cindy compatibility identity.
|
|
23
|
+
The ledger lives under the active DSH profile state directory. Pending payloads and attachments are retained until persistent user-history verification or sealed cancellation; terminal identity fences do not expire silently. Do not delete state to repair an uncertain send. Back up that state before rollback: older Host releases cannot replay this ledger.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
All changes are Host-only: no Cindy or DSH core patch is required. The handshake compatibility stamp is separate from the actual plugin version and explicit v1 capability. Unsupported optional features are refused; this is not a promise of all native Cindy features or exactly-once external tool effects.
|
|
25
26
|
|
|
26
|
-
##
|
|
27
|
+
## Previous release and rollback
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
0.1.26 added editable, locally persistent Host names and desktop-safe plain-text queue content. Reinstalling 0.1.26 is possible, but pause input first and preserve the 0.1.27 ledger: downgrading disables durable-input recovery until 0.1.27 is restored.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cindy-host",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"device-link"
|
|
17
17
|
],
|
|
18
18
|
"engines": {
|
|
19
|
-
"node": ">=22"
|
|
19
|
+
"node": ">=22.16.0"
|
|
20
20
|
},
|
|
21
21
|
"//files": "白名单,而非整个目录:.sandbox/(含凭据与会话)、.claw/runtime、验收日志、开发记录都不进包。见 doc/publishing.md §3。",
|
|
22
22
|
"files": [
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
},
|
|
72
72
|
"//peerDependencies": "DSH 自己的包必须由宿主提供,绝不能声明成 dependency/optionalDependency:pnpm 会把它们**连同整代依赖图**装进 profile,宿主再组合这个混合体就起不来(实测:`^0.1.1-rc.2` 会把 28 个 0.1.1-rc.2 的包装进一个 0.1.5-rc.2 的宿主,profile 启动失败于 dsh-client-file-upload / dsh-session)。区间必须覆盖宿主那一代(semver 的预发布规则:`^0.1.1-rc.2` 匹配不到 `0.1.5-rc.2`),与生态里 dsh-codex / dsh-cost-meter 的写法一致;`0.2.0-rc.2` 需要 `>=0.2.0-rc.1 <0.3.0-0`。",
|
|
73
73
|
"peerDependencies": {
|
|
74
|
-
"@deepseek-ai/dsh-session": "
|
|
75
|
-
"@deepseek-ai/dsh-settings": "
|
|
74
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.3",
|
|
75
|
+
"@deepseek-ai/dsh-settings": "0.2.1-rc.3",
|
|
76
76
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
77
77
|
},
|
|
78
78
|
"//peerDependenciesMeta": "**故意没有这个字段。** `@deepseek-ai/dsh-host-apiproxy` 曾经在这里标 optional,但 0.2 已经删掉了这个包,而 `evaluatePluginCompatibility` 会遍历 `peerDependencies` 的**每一个键且不查 `peerDependenciesMeta`**,所以只要它出现在 peers 里,`dsh plugin add` 就会直接拒绝安装(`installation rejected`),启动时 bundle 也会被静默跳过、`dsh-cindy-host` 行根本不会出现。该适配器本来就走惰性 `require` + 失败降级(见 src/dsh-plugin.js 的 apiProxy 分支),从 peers 里移除不改变任何运行时行为,只是让两道门禁都能正确判定。",
|
package/src/cindy-channels.js
CHANGED
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Everything outside that subset answers `CHANNEL_NOT_ALLOWED`, which is exactly
|
|
11
11
|
* what a Cindy controlled device answers for a channel it does not serve. That
|
|
12
|
-
* is a
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* phone would show a broken feature instead of an absent one.
|
|
12
|
+
* is a permanent refusal, not proof of a working fallback. Only optional
|
|
13
|
+
* features with a verified caller fallback may use that boundary; the latest
|
|
14
|
+
* default input/durable-outbox path must be implemented end to end.
|
|
16
15
|
*/
|
|
17
16
|
import { agentKindForModel, toCindyActiveSessions, toCindySessionList, toCindySessionListRow } from './cindy-session-row.js';
|
|
18
17
|
import { toAgentCapabilities, toCindyPermissionMode, toDshPermissionMode, toNewMakerDefaults, toPermissionOptions, toProviderList } from './host-models.js';
|
|
@@ -20,6 +19,12 @@ import { queueItemsFromInbox } from './host-input-queue.js';
|
|
|
20
19
|
import { toAgentSkills, toAgentCommands, toAtResources } from './host-palette.js';
|
|
21
20
|
import { toGoalStatusPayload } from './host-goals.js';
|
|
22
21
|
import { hostAttachmentsOf } from './host-attachments.js';
|
|
22
|
+
import { historyLocateOptions, locateCommittedMessages, capReferenceRows } from './host-history-locate.js';
|
|
23
|
+
|
|
24
|
+
async function deliveryReply(request, operation) {
|
|
25
|
+
try { return invokeResult(request, await operation()); }
|
|
26
|
+
catch (error) { return invokeError(request, typeof error?.code === 'string' ? error.code : 'DELIVERY_FAILURE', String(error?.message ?? error)); }
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
/** The phone's own unsubscribe channel, paired with subscribe. */
|
|
25
30
|
const SUBSCRIBE = 'device-link:subscribe';
|
|
@@ -211,6 +216,8 @@ export const SUPPORTED_CHANNELS = Object.freeze([
|
|
|
211
216
|
'local-db:sessions:list',
|
|
212
217
|
'local-db:sessions:get',
|
|
213
218
|
'local-db:messages:list',
|
|
219
|
+
'local-db:messages:around',
|
|
220
|
+
'local-db:messages:around-client-id',
|
|
214
221
|
'local-db:sessions:patch-meta',
|
|
215
222
|
'maker:create-session',
|
|
216
223
|
'maker:send',
|
|
@@ -629,6 +636,24 @@ export function createChannelRouter({
|
|
|
629
636
|
return invokeResult(request, row);
|
|
630
637
|
}
|
|
631
638
|
|
|
639
|
+
if (channel === 'local-db:messages:around' || channel === 'local-db:messages:around-client-id') {
|
|
640
|
+
const capabilities = capabilitiesNow();
|
|
641
|
+
if (typeof args[0] !== 'string' || !args[0] || typeof args[1] !== 'string' || !args[1]) return invokeError(request, 'INVALID_PARAMS', 'sessionId and message identity must be nonempty strings');
|
|
642
|
+
const sessionId = args[0];
|
|
643
|
+
if (capabilities.sessionHidden?.(sessionId) === true) return invokeError(request, 'NOT_FOUND', 'Session does not exist');
|
|
644
|
+
if (typeof capabilities.readAllMessages !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This Host cannot read committed message history');
|
|
645
|
+
try {
|
|
646
|
+
const byClientId = channel === 'local-db:messages:around-client-id';
|
|
647
|
+
const options = historyLocateOptions(byClientId ? args[2] : {radius: args[2]?.radius});
|
|
648
|
+
const rows = locateCommittedMessages(await capabilities.readAllMessages(sessionId), {sessionId, identity: args[1], byClientId, radius: options.radius});
|
|
649
|
+
const hydrated = typeof capabilities.hydrateImages === 'function' ? await capabilities.hydrateImages(rows) : rows;
|
|
650
|
+
return invokeResult(request, capReferenceRows(hydrated, options.contentCharLimit));
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (error?.code === 'NOT_FOUND' || error?.code === 'INVALID_PARAMS' || error?.code === 'NOT_AVAILABLE') return invokeError(request, error.code, error.message);
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
632
657
|
if (channel === 'local-db:messages:list') {
|
|
633
658
|
const sessionId = String(args[0] ?? '');
|
|
634
659
|
const read = capabilitiesNow().readMessages;
|
|
@@ -1081,6 +1106,12 @@ export function createChannelRouter({
|
|
|
1081
1106
|
const sessionId = String(args[0] ?? '');
|
|
1082
1107
|
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a session id`);
|
|
1083
1108
|
|
|
1109
|
+
if (typeof capabilities.inputDelivery === 'function') {
|
|
1110
|
+
let delivery;
|
|
1111
|
+
try { delivery = capabilities.inputDelivery(); } catch (error) { return deliveryReply(request, () => { throw error; }); }
|
|
1112
|
+
if (delivery) return deliveryReply(request, () => channel === 'maker:input:steer' ? delivery.steer(sessionId, args[1], args[2] ?? {}) : delivery.accept(sessionId, args[1], args[2] ?? {}));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1084
1115
|
// A `steer` naming an item DSH already holds is a **promotion** of that
|
|
1085
1116
|
// queued item, not a new message: the controller sends the queued row back
|
|
1086
1117
|
// with `removeFromQueue`. Re-sending its text would duplicate it, which is
|
|
@@ -1288,6 +1319,19 @@ export function createChannelRouter({
|
|
|
1288
1319
|
const control = capabilities.queueControl;
|
|
1289
1320
|
const clientId = typeof args[1] === 'string' ? args[1] : (typeof args[1]?.clientId === 'string' ? args[1].clientId : null);
|
|
1290
1321
|
|
|
1322
|
+
let durableQueue = null;
|
|
1323
|
+
if (typeof capabilities.inputDelivery === 'function') {
|
|
1324
|
+
try { durableQueue = capabilities.inputDelivery(); } catch (error) { return deliveryReply(request, () => { throw error; }); }
|
|
1325
|
+
}
|
|
1326
|
+
if (durableQueue && ['maker:input:remove', 'maker:input:clear-session', 'maker:input:update-text', 'maker:input:update-content'].includes(channel)) {
|
|
1327
|
+
return deliveryReply(request, async () => {
|
|
1328
|
+
const delivery = durableQueue;
|
|
1329
|
+
if (channel === 'maker:input:clear-session') return delivery.clear(sessionId);
|
|
1330
|
+
if (channel === 'maker:input:remove') return delivery.cancel(sessionId, clientId);
|
|
1331
|
+
return delivery.edit(sessionId, clientId, args[2]);
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1291
1335
|
// Controller-owned queue UI state. The controller sets it here and reads it
|
|
1292
1336
|
// back out of the projection, so it must be recorded rather than ignored.
|
|
1293
1337
|
if (channel === 'maker:input:set-expanded') {
|
|
@@ -1318,7 +1362,7 @@ export function createChannelRouter({
|
|
|
1318
1362
|
// is exactly what the controller asks for: it only ever sends
|
|
1319
1363
|
// `keepQueue: true` (see `stopOptionsForProjection`).
|
|
1320
1364
|
try {
|
|
1321
|
-
control.cancel({ sessionId });
|
|
1365
|
+
await control.cancel({ sessionId });
|
|
1322
1366
|
} catch (error) {
|
|
1323
1367
|
return invokeError(request, 'THREW', String(error?.message ?? error));
|
|
1324
1368
|
}
|
|
@@ -1406,6 +1450,9 @@ export function createChannelRouter({
|
|
|
1406
1450
|
const sessionId = String(args[0] ?? '');
|
|
1407
1451
|
const clientId = typeof args[1] === 'string' ? args[1] : null;
|
|
1408
1452
|
if (sessionId === '' || clientId === null) return invokeError(request, 'BAD_REQUEST', 'maker:input:move needs a session and an item id');
|
|
1453
|
+
if (typeof capabilities.inputDelivery === 'function') {
|
|
1454
|
+
try { if (capabilities.inputDelivery()) return invokeError(request, 'NOT_AVAILABLE', 'DSH has no public durable inbox reorder operation'); } catch (error) { return deliveryReply(request, () => { throw error; }); }
|
|
1455
|
+
}
|
|
1409
1456
|
// Reordering is presentation: DSH's inbox keeps its own order and a
|
|
1410
1457
|
// reordered row would snap back on the next frame, so this Host records
|
|
1411
1458
|
// the new order locally and says nothing it cannot keep.
|
|
@@ -1433,6 +1480,12 @@ export function createChannelRouter({
|
|
|
1433
1480
|
const clientId = typeof args[1]?.clientId === 'string' && args[1].clientId !== ''
|
|
1434
1481
|
? args[1].clientId
|
|
1435
1482
|
: (typeof args[2]?.clientId === 'string' && args[2].clientId !== '' ? args[2].clientId : null);
|
|
1483
|
+
const capabilities = capabilitiesNow();
|
|
1484
|
+
if (typeof capabilities.inputDelivery === 'function') {
|
|
1485
|
+
let delivery;
|
|
1486
|
+
try { delivery = capabilities.inputDelivery(); } catch (error) { return deliveryReply(request, () => { throw error; }); }
|
|
1487
|
+
if (delivery) return deliveryReply(request, async () => { await delivery.accept(sessionId, { clientId: clientId ?? request.id, text: text ?? '', files: sendAttachments }, args[2] ?? {}); return { accepted: true }; });
|
|
1488
|
+
}
|
|
1436
1489
|
const sent = await send({
|
|
1437
1490
|
sessionId,
|
|
1438
1491
|
text: text ?? '',
|
|
@@ -1577,6 +1630,11 @@ export function createChannelRouter({
|
|
|
1577
1630
|
if (channel === 'maker:input:get-projection') {
|
|
1578
1631
|
const capabilities = capabilitiesNow();
|
|
1579
1632
|
const sessionId = String(args[0] ?? '');
|
|
1633
|
+
if (typeof capabilities.inputDelivery === 'function') {
|
|
1634
|
+
let delivery;
|
|
1635
|
+
try { delivery = capabilities.inputDelivery(); } catch (error) { return deliveryReply(request, () => { throw error; }); }
|
|
1636
|
+
if (delivery) return deliveryReply(request, () => delivery.projection(sessionId, args[1]));
|
|
1637
|
+
}
|
|
1580
1638
|
// Read what DSH actually holds before falling back to the fold: a fold that
|
|
1581
1639
|
// never heard a `queue` frame reports an empty queue, which is how the
|
|
1582
1640
|
// controller came to keep a 队列中 row for a message the agent had answered.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createAttachmentMaterializer, hostAttachmentsOf } from './host-attachments.js';
|
|
2
|
+
import { foldSessionEvent } from './dsh-message-fold.js';
|
|
3
|
+
import { queuedRowFromController } from './host-input-queue.js';
|
|
4
|
+
import { deliveryError } from './host-input-delivery-store.js';
|
|
5
|
+
|
|
6
|
+
/** Replay only a validated persisted prefix. A claim removal is not a user receipt. */
|
|
7
|
+
export function durableInputEvidence(events,sessionId) {
|
|
8
|
+
const queues={'next-turn':[],'next-step':[]};const users=[];
|
|
9
|
+
if(!Array.isArray(events))throw deliveryError('DELIVERY_EVIDENCE_UNAVAILABLE','Stored session log has no event array');
|
|
10
|
+
for(let i=0;i<events.length;i++){
|
|
11
|
+
const event=events[i];if(event.seq!==i)throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Stored session prefix is not contiguous');
|
|
12
|
+
if(event.type==='agent/inbox/spliced'){
|
|
13
|
+
const s=event.data;const previous=queues[s?.target];const count=s?.removedCount??0;
|
|
14
|
+
if(!previous||!Number.isSafeInteger(s.start)||s.start<0||s.start>previous.length||!Number.isSafeInteger(count)||count<0||s.start+count>previous.length||!Array.isArray(s.inserted))throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Stored inbox splice is invalid');
|
|
15
|
+
const next=[...previous];next.splice(s.start,count,...s.inserted);queues[s.target]=next;
|
|
16
|
+
const ids=[...queues['next-turn'],...queues['next-step']].map(m=>m.id);if(ids.some(id=>typeof id!=='string'||!id)||new Set(ids).size!==ids.length)throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Stored inbox identities are invalid');
|
|
17
|
+
}
|
|
18
|
+
const source=event.data?.source;
|
|
19
|
+
if(event.type==='user/message'&&source?.kind==='user'&&typeof source.rpcId==='string'&&source.rpcId)users.push({clientId:source.rpcId,seq:event.seq});
|
|
20
|
+
}
|
|
21
|
+
const inbox=Object.entries(queues).flatMap(([placement,items])=>items.map(message=>({id:message.id,clientId:message.source?.kind==='user'&&typeof message.source.rpcId==='string'?message.source.rpcId:message.id,placement,message,foreign:message.source?.kind!=='user'})));
|
|
22
|
+
return {sessionId,users,inbox,throughSeq:events.length-1};
|
|
23
|
+
}
|
|
24
|
+
export function createDshInputDeliveryBackend({getServices,sessionController,getSession}){
|
|
25
|
+
let active=true;
|
|
26
|
+
function services(){if(!active)throw deliveryError('DELIVERY_BACKEND_STOPPED','Input delivery service was detached');const s=getServices();if(!s.sessions||typeof s.sessions.flush!=='function'||!s.persistence||typeof s.persistence.open!=='function')throw deliveryError('DELIVERY_NOT_AVAILABLE','Durable DSH session services are not mounted');return s;}
|
|
27
|
+
async function readLog(sid){
|
|
28
|
+
const s=services();const live=s.sessions.get(sid);
|
|
29
|
+
if(live && !await s.sessions.flush(live))throw deliveryError('DELIVERY_DURABILITY_UNAVAILABLE','No session durability listener participated');
|
|
30
|
+
const handle=await s.persistence.open(sid,'read',{signal:AbortSignal.timeout(30000)});
|
|
31
|
+
try{const result=await handle.read(0,Number.MAX_SAFE_INTEGER,{signal:AbortSignal.timeout(30000)});const events=result.events;durableInputEvidence(events,sid);return events;}finally{await handle.close();}
|
|
32
|
+
}
|
|
33
|
+
async function agentFor(sid){const resolved=await sessionController.resolveAgent(sid);if(resolved?.error)throw resolved.error;if(!resolved?.agent)throw deliveryError('DELIVERY_AGENT_UNAVAILABLE','DSH did not resolve an agent');return resolved.agent;}
|
|
34
|
+
async function flush(sid){const s=services();const session=s.sessions.get(sid);if(!session||!await s.sessions.flush(session))throw deliveryError('DELIVERY_DURABILITY_UNAVAILABLE','Cannot verify the DSH mutation durability');}
|
|
35
|
+
const backend={
|
|
36
|
+
available(){try{services();return true;}catch{return false;}},
|
|
37
|
+
stop(){active=false;},
|
|
38
|
+
async readRows(sid){const events=await readLog(sid);const rows=[];for(const event of events){const folded=foldSessionEvent(event,{sessionId:sid});folded.forEach((row,index)=>{const rowid=event.seq*65536+index;if(!Number.isSafeInteger(rowid))throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Message order exceeds the safe protocol range');rows.push({...row,rowid});});}return rows.reverse();},
|
|
39
|
+
async evidence(sid){const events=await readLog(sid);const proof=durableInputEvidence(events,sid);const agent=getServices().agents?.get(sid);return {...proof,running:agent?.status==='running',live:agent!==undefined};},
|
|
40
|
+
async prepare(sid,row,{resolveAttachmentRef,mode='queue'}={}){
|
|
41
|
+
services();const session=await getSession(sid);if(!session)throw deliveryError('NOT_FOUND','Session does not exist');
|
|
42
|
+
const incoming=hostAttachmentsOf(row);if(Array.isArray(row.files)&&incoming.length!==row.files.length)throw deliveryError('ATTACHMENT_UNAVAILABLE','Every submitted attachment must be persistently obtainable');
|
|
43
|
+
const text=typeof row.text==='string'?row.text:'';if(!text.trim()&&!incoming.length)throw deliveryError('BAD_REQUEST','Input has no text or attachment');
|
|
44
|
+
const materializer=createAttachmentMaterializer({fileSystem:getServices().fileSystem,resolveAttachmentRef,captureOnly:true});
|
|
45
|
+
const owned=await materializer.materialize({attachments:incoming,signal:AbortSignal.timeout(30000)});
|
|
46
|
+
if(owned.dropped.length)throw deliveryError('ATTACHMENT_UNAVAILABLE','Cannot durably receive all attachments: '+owned.dropped.map(x=>x.reason).join(', '));
|
|
47
|
+
const queued=queuedRowFromController({clientId:row.clientId,text,session});if(!queued)throw deliveryError('DELIVERY_SESSION_UNAVAILABLE','Session working directory is unavailable');
|
|
48
|
+
return {payload:{text,assets:owned.parts,consumed:owned.consumed,mode},queued:{...queued,files:incoming,chatMessage:{...queued.chatMessage,...(row.chatMessage?.images?{images:row.chatMessage.images}:{})}}};
|
|
49
|
+
},
|
|
50
|
+
async dispatch(entry,{releaseAttachmentRef}={}){
|
|
51
|
+
let agent;
|
|
52
|
+
try{agent=await agentFor(entry.sessionId);}catch(error){error.deliveryNotAdmitted=true;throw error;}
|
|
53
|
+
const content=entry.payload.text?[{type:'text',text:entry.payload.text}]:[];
|
|
54
|
+
for(const asset of entry.payload.assets??[]){
|
|
55
|
+
if(asset.mediaType)content.push({type:'image',mediaType:asset.mediaType,data:asset.data,name:asset.name});
|
|
56
|
+
else {try{const uploads=getServices().fileUploads;if(typeof uploads?.upload!=='function')throw deliveryError('ATTACHMENT_UNAVAILABLE','File upload service is unavailable');const uploaded=await uploads.upload(agent,{data:asset.data,name:asset.name},AbortSignal.timeout(30000));if(!uploaded?.receiptId)throw deliveryError('ATTACHMENT_UNAVAILABLE','No file receipt was created');content.push({type:'file',receiptId:uploaded.receiptId});}catch(error){error.deliveryNotAdmitted=true;throw error;}}
|
|
57
|
+
}
|
|
58
|
+
// Do not interpret an aborted signal as proof that admission did not happen.
|
|
59
|
+
const mode=entry.payload.mode==='steer'&&agent.status==='running'?'steer':'queue';
|
|
60
|
+
try{await sessionController.prompt({sessionId:entry.sessionId,requestId:entry.clientId,mode,content},AbortSignal.timeout(30000));}
|
|
61
|
+
catch(error){
|
|
62
|
+
const trace=agent.session.snapshotEvents().some(event=>event.type==='user/message'&&event.data?.source?.rpcId===entry.clientId || event.type==='agent/inbox/spliced'&&event.data?.inserted?.some(message=>message.source?.rpcId===entry.clientId));
|
|
63
|
+
if(!trace){error.deliveryNotAdmitted=true;if(mode==='steer')error.deliveryRetryMode='queue';}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
await flush(entry.sessionId);await readLog(entry.sessionId);
|
|
67
|
+
for(const key of entry.payload.consumed??[])if(typeof releaseAttachmentRef==='function')void releaseAttachmentRef(key).catch(()=>{});
|
|
68
|
+
},
|
|
69
|
+
async promote(sid,itemId){await sessionController.updateQueue({sessionId:sid,itemId,action:{kind:'steer'}});await flush(sid);await readLog(sid);},
|
|
70
|
+
async remove(sid,itemId){
|
|
71
|
+
await sessionController.updateQueue({sessionId:sid,itemId,action:{kind:'remove'}});
|
|
72
|
+
try{await flush(sid);await readLog(sid);}catch(error){error.deliveryRemovalApplied=true;throw error;}
|
|
73
|
+
},
|
|
74
|
+
async recoverColdInbox(sid,entries){
|
|
75
|
+
const initial=await backend.evidence(sid);if(initial.running||initial.inbox.some(item=>item.foreign))return false;
|
|
76
|
+
const owned=new Map(entries.filter(e=>e.payload&&e.phase!=='accepted'&&e.phase!=='removed').map(e=>[e.clientId,e]));
|
|
77
|
+
if(initial.inbox.some(item=>!owned.has(item.clientId)))return false;
|
|
78
|
+
const order=initial.inbox.map(x=>owned.get(x.clientId).ordinal);if(order.some((n,i)=>i>0&&n<order[i-1]))return false;
|
|
79
|
+
const agent=await agentFor(sid);if(agent.status==='running')return false;
|
|
80
|
+
// A public remove+durable checkpoint before re-admission is the only verified wake compensation.
|
|
81
|
+
for(const item of initial.inbox){if(agent.status==='running')throw deliveryError('DELIVERY_RECOVERY_WAITING','Agent became active during cold queue recovery');await backend.remove(sid,item.id);}
|
|
82
|
+
const after=await backend.evidence(sid);return !after.running&&!after.inbox.length;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return backend;
|
|
86
|
+
}
|
package/src/dsh-plugin.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import Schema from '@deepseek-ai/schemastery';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createDshInputDeliveryBackend } from './dsh-input-delivery.js';
|
|
3
6
|
import { DEFAULT_HOST_SETTINGS, SETTINGS_NAMESPACE, validateHostSettings } from './host-settings.js';
|
|
4
7
|
import { DshHostSource } from './dsh-host-source.js';
|
|
5
8
|
import { createSessionControllerSource } from './dsh-session-source.js';
|
|
@@ -654,10 +657,21 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
654
657
|
* (`readMessages.all`), so the two views can never disagree about what the session
|
|
655
658
|
* contains — and neither of them re-reads the log to answer.
|
|
656
659
|
*/
|
|
660
|
+
const deliveryServices = { sessions: undefined, persistence: undefined, agents: undefined };
|
|
661
|
+
ctx.inject(['sessions', 'sessionPersistence', 'agents'], deliveryCtx => {
|
|
662
|
+
deliveryServices.sessions = deliveryCtx.get('sessions'); deliveryServices.persistence = deliveryCtx.get('sessionPersistence'); deliveryServices.agents = deliveryCtx.get('agents');
|
|
663
|
+
return () => { deliveryServices.sessions = undefined; deliveryServices.persistence = undefined; deliveryServices.agents = undefined; };
|
|
664
|
+
});
|
|
665
|
+
const inputDeliveryBackend = createDshInputDeliveryBackend({
|
|
666
|
+
getServices: () => ({ ...deliveryServices, fileSystem: ctx.get('fs'), fileUploads: controls.fileUploads }),
|
|
667
|
+
sessionController,
|
|
668
|
+
getSession: (sessionId) => sessionSource.getSession(sessionId),
|
|
669
|
+
});
|
|
670
|
+
const committedRows = (sessionId) => inputDeliveryBackend.available() ? inputDeliveryBackend.readRows(sessionId) : readMessages.all(sessionId);
|
|
657
671
|
const historyView = readMessages === undefined
|
|
658
672
|
? undefined
|
|
659
673
|
: createHistoryViewController({
|
|
660
|
-
rows:
|
|
674
|
+
rows: committedRows,
|
|
661
675
|
// The turn state has to be **wired** here; the controller's own default is `() => false`.
|
|
662
676
|
// That default is why a running turn was never marked `isStreaming`, which cost two things
|
|
663
677
|
// at once and looked like a client problem both times: the phone drew no live card, and
|
|
@@ -708,6 +722,8 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
708
722
|
return {
|
|
709
723
|
kind: 'session-controller',
|
|
710
724
|
readMessages,
|
|
725
|
+
readAllMessages: readMessages === undefined ? undefined : committedRows,
|
|
726
|
+
inputDeliveryBackend,
|
|
711
727
|
countMessages,
|
|
712
728
|
historyView,
|
|
713
729
|
// Reported through the status route: "the photo does not render" has three
|
|
@@ -1345,6 +1361,8 @@ export function buildDiagnostics({ runtime, sourceKind, seam, listingDiagnostics
|
|
|
1345
1361
|
export function capabilityProvider(sources) {
|
|
1346
1362
|
return {
|
|
1347
1363
|
readMessages: sources.readMessages,
|
|
1364
|
+
readAllMessages: sources.readAllMessages,
|
|
1365
|
+
inputDeliveryBackend: sources.inputDeliveryBackend,
|
|
1348
1366
|
countMessages: sources.countMessages,
|
|
1349
1367
|
// The work-grouped history window the controller reaches for once it has seen
|
|
1350
1368
|
// `history-view-v1`; without this forwarding the three channels would answer
|
|
@@ -1726,6 +1744,7 @@ export function apply(ctx, config) {
|
|
|
1726
1744
|
// The controller's archive/delete/pin writes go straight to this plugin's
|
|
1727
1745
|
// settings section, so the whole flag store reloads with the process.
|
|
1728
1746
|
persistSessionFlags: sessionFlagsWriter(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
|
|
1747
|
+
inputDeliveryDirectory: join(process.env.DSH_PROFILE_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', process.env.DSH_PROFILE || 'desktop'), 'state', 'dsh-cindy-host'),
|
|
1729
1748
|
});
|
|
1730
1749
|
// Settings can change while startHost awaits authentication. Reconcile the
|
|
1731
1750
|
// latest snapshot after the runtime exists instead of dropping that update.
|
package/src/host-attachments.js
CHANGED
|
@@ -87,7 +87,7 @@ export function imageMediaTypeOf(attachment) {
|
|
|
87
87
|
* `consumed` being the staging keys whose bytes were taken, for the caller to
|
|
88
88
|
* release once the prompt has landed.
|
|
89
89
|
*/
|
|
90
|
-
export function createAttachmentMaterializer({ fileSystem, fileUploads, resolveAttachmentRef, maxBytes = MAX_ATTACHMENT_BYTES } = {}) {
|
|
90
|
+
export function createAttachmentMaterializer({ fileSystem, fileUploads, resolveAttachmentRef, maxBytes = MAX_ATTACHMENT_BYTES, captureOnly = false } = {}) {
|
|
91
91
|
/** Read one whole file through the composed filesystem, or say why not. */
|
|
92
92
|
async function readWholeFile(path) {
|
|
93
93
|
if (fileSystem === undefined || fileSystem === null) return { ok: false, reason: 'no-filesystem' };
|
|
@@ -125,6 +125,7 @@ export function createAttachmentMaterializer({ fileSystem, fileUploads, resolveA
|
|
|
125
125
|
* depend on where the bytes came from.
|
|
126
126
|
*/
|
|
127
127
|
async function partFor({ buffer, name, mediaType, agent, signal }) {
|
|
128
|
+
if (captureOnly) return { part: { name, mediaType, data: buffer.toString('base64') } };
|
|
128
129
|
if (mediaType !== null) return { part: { type: 'image', mediaType, data: buffer.toString('base64'), name } };
|
|
129
130
|
if (typeof fileUploads?.upload !== 'function') return { dropped: 'no-upload-service' };
|
|
130
131
|
try {
|
package/src/host-frame-budget.js
CHANGED
|
@@ -275,7 +275,18 @@ export function fitInvokeResultToFrame({ reply, request, channel, budgetBytes =
|
|
|
275
275
|
}
|
|
276
276
|
const placeholders = placeholderRows(rows);
|
|
277
277
|
for (let keep = placeholders.length - 1; keep > 0; keep -= 1) {
|
|
278
|
-
|
|
278
|
+
let sliced;
|
|
279
|
+
if (channel === 'local-db:messages:around' || channel === 'local-db:messages:around-client-id') {
|
|
280
|
+
const identity = request?.payload?.args?.[1];
|
|
281
|
+
const anchor = placeholders.findIndex(row => (channel.endsWith('around-client-id') ? row?.clientId : row?.id) === identity);
|
|
282
|
+
if (anchor < 0) break;
|
|
283
|
+
let from = Math.max(0, Math.min(anchor - Math.floor((keep - 1) / 2), placeholders.length - keep));
|
|
284
|
+
let to = from + keep;
|
|
285
|
+
const same = (a, b) => typeof a?.id === 'string' && typeof b?.id === 'string' && /:[0-9]+$/.test(a.id) && /:[0-9]+$/.test(b.id) && a.id.replace(/:[0-9]+$/, '') === b.id.replace(/:[0-9]+$/, '');
|
|
286
|
+
while (from > 0 && same(placeholders[from - 1], placeholders[from])) from--;
|
|
287
|
+
while (to < placeholders.length && same(placeholders[to - 1], placeholders[to])) to++;
|
|
288
|
+
sliced = placeholders.slice(from, to);
|
|
289
|
+
} else sliced = sliceRowsAtMessageBoundary(placeholders, keep);
|
|
279
290
|
const trimmed = sliced.map((row) => (row === null || typeof row !== 'object' ? row : { ...row, agentMeta: markRowsTrimmed(row.agentMeta, placeholders.length) }));
|
|
280
291
|
const payload = { ...original, result: trimmed };
|
|
281
292
|
const bytes = measure(payload);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Latest Cindy around/around-client-id contract over the one committed transcript. */
|
|
2
|
+
function fail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
3
|
+
export function historyLocateOptions(options = {}) {
|
|
4
|
+
const value = Number(options?.radius);
|
|
5
|
+
const radius = !Number.isFinite(value) || value < 0 ? 60 : Math.min(Math.floor(value), 200);
|
|
6
|
+
const limit = options?.contentCharLimit ?? null;
|
|
7
|
+
if (limit !== null && (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 8000)) fail('INVALID_PARAMS', 'contentCharLimit must be an integer between 1 and 8000 or null');
|
|
8
|
+
return { radius, contentCharLimit: limit };
|
|
9
|
+
}
|
|
10
|
+
export function locateCommittedMessages(rows, { sessionId, identity, byClientId, radius, clearBoundaryMs = null }) {
|
|
11
|
+
if (!Array.isArray(rows)) fail('NOT_AVAILABLE', 'Committed message history is unavailable');
|
|
12
|
+
const ordered = rows.map((row, index) => ({ row, rank: Number.isFinite(row?.rowid) ? row.rowid : rows.length - index }))
|
|
13
|
+
.filter(({row}) => row && (row.sessionId === undefined || row.sessionId === sessionId) && row.rewindAt == null && (clearBoundaryMs === null || Date.parse(row.createdAt) > clearBoundaryMs))
|
|
14
|
+
.sort((a,b) => Date.parse(a.row.createdAt) - Date.parse(b.row.createdAt) || a.rank - b.rank);
|
|
15
|
+
const at = ordered.findIndex(({row}) => (byClientId ? row.clientId : row.id) === identity);
|
|
16
|
+
if (at < 0) fail('NOT_FOUND', 'Message does not exist in this session');
|
|
17
|
+
return ordered.slice(Math.max(0, at-radius), at+radius+1).map(({row})=>row);
|
|
18
|
+
}
|
|
19
|
+
function preview(content) {
|
|
20
|
+
if (typeof content === 'string') return content;
|
|
21
|
+
if (Array.isArray(content)) return content.map(part => typeof part === 'string' ? part : part?.type === 'text' && typeof part.text === 'string' ? part.text : '').filter(Boolean).join(String.fromCharCode(10));
|
|
22
|
+
if (content && typeof content === 'object') { if(typeof content.text === 'string')return content.text; if(typeof content.content === 'string')return content.content; }
|
|
23
|
+
return JSON.stringify(content) ?? '';
|
|
24
|
+
}
|
|
25
|
+
function omitsData(content) {
|
|
26
|
+
if (Array.isArray(content)) return content.some(part => typeof part !== 'string' && !(part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string'));
|
|
27
|
+
if (!content || typeof content !== 'object') return false;
|
|
28
|
+
if (typeof content.text !== 'string' && typeof content.content !== 'string') return true;
|
|
29
|
+
return content.quotesEncoded === true || ['images','files','sessionReferences','pastedTextRanges','slashCommandRanges'].some(key=>Array.isArray(content[key]) && content[key].length>0);
|
|
30
|
+
}
|
|
31
|
+
export function capReferenceRows(rows, limit) {
|
|
32
|
+
if(limit === null)return rows;
|
|
33
|
+
return rows.map(row=>{const text=preview(row.content);const lossy=text.length>limit || omitsData(row.content);
|
|
34
|
+
if(!lossy)return typeof row.content === 'string' ? row : {...row,content:text};
|
|
35
|
+
const content=text.length<=limit ? text : limit===1 ? '…' : '…'+text.slice(-(limit-1));
|
|
36
|
+
const prior=row.agentMeta && typeof row.agentMeta==='object' && !Array.isArray(row.agentMeta) ? row.agentMeta : {};
|
|
37
|
+
return {...row,content,agentMeta:{...prior,remoteContentTruncated:true}};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const epochKey = Symbol.for('dsh-cindy-host.process-epoch');
|
|
7
|
+
export const INPUT_PROCESS_EPOCH = globalThis[epochKey] ??= randomUUID();
|
|
8
|
+
export function deliveryError(code, message) { return Object.assign(new Error(message), {code}); }
|
|
9
|
+
function processAlive(pid) {
|
|
10
|
+
try { process.kill(pid, 0); return true; } catch (error) { if(error.code === 'ESRCH') return false; return true; }
|
|
11
|
+
}
|
|
12
|
+
/** A private, single-writer FULL-synchronous ledger. No input or tombstone expires implicitly. */
|
|
13
|
+
export function openInputDeliveryStore({ file, namespace, epoch=INPUT_PROCESS_EPOCH, pid=process.pid, isAlive=processAlive }) {
|
|
14
|
+
if(!isAbsolute(file) || typeof namespace !== 'string' || !namespace) throw new TypeError('An absolute delivery database path and owner namespace are required');
|
|
15
|
+
mkdirSync(dirname(file), {recursive:true,mode:0o700});
|
|
16
|
+
const db = new DatabaseSync(file);
|
|
17
|
+
const owner = randomUUID();
|
|
18
|
+
let closed=false, inTransaction=false;
|
|
19
|
+
try {
|
|
20
|
+
db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;');
|
|
21
|
+
const version=db.prepare('PRAGMA user_version').get().user_version;
|
|
22
|
+
if(version!==0 && version!==1) throw deliveryError('DELIVERY_SCHEMA_UNSUPPORTED','Unsupported input delivery database version');
|
|
23
|
+
db.exec('CREATE TABLE IF NOT EXISTS owner (singleton INTEGER PRIMARY KEY CHECK(singleton=1), instance TEXT NOT NULL, epoch TEXT NOT NULL, pid INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS sessions (namespace TEXT NOT NULL, session_id TEXT NOT NULL, boundary INTEGER, PRIMARY KEY(namespace,session_id)); CREATE TABLE IF NOT EXISTS inputs (ordinal INTEGER PRIMARY KEY AUTOINCREMENT, namespace TEXT NOT NULL, session_id TEXT NOT NULL, client_id TEXT NOT NULL, data TEXT NOT NULL, UNIQUE(namespace,session_id,client_id)); PRAGMA user_version=1;');
|
|
24
|
+
db.exec('BEGIN IMMEDIATE');
|
|
25
|
+
try {
|
|
26
|
+
const previous=db.prepare('SELECT * FROM owner WHERE singleton=1').get();
|
|
27
|
+
if(previous && (previous.pid===pid ? previous.epoch===epoch : isAlive(previous.pid))) throw deliveryError('DELIVERY_OWNER_ACTIVE','Another live Host still owns the input delivery database');
|
|
28
|
+
db.prepare('INSERT OR REPLACE INTO owner VALUES (1,?,?,?)').run(owner,epoch,pid);
|
|
29
|
+
db.exec('COMMIT');
|
|
30
|
+
} catch(error) { db.exec('ROLLBACK'); throw error; }
|
|
31
|
+
} catch(error) {db.close();throw error;}
|
|
32
|
+
function guard() {
|
|
33
|
+
if(closed)throw deliveryError('DELIVERY_STORE_CLOSED','Input delivery store is closed');
|
|
34
|
+
if(db.prepare('SELECT instance FROM owner WHERE singleton=1').get()?.instance!==owner)throw deliveryError('DELIVERY_OWNER_LOST','Input delivery writer ownership was replaced');
|
|
35
|
+
}
|
|
36
|
+
function writeGuard(){guard();if(!inTransaction)throw new Error('Delivery mutation requires a transaction');}
|
|
37
|
+
const store={
|
|
38
|
+
epoch,pid,
|
|
39
|
+
transaction(fn){
|
|
40
|
+
guard();if(inTransaction)throw new Error('Nested delivery transaction');
|
|
41
|
+
db.exec('BEGIN IMMEDIATE');inTransaction=true;
|
|
42
|
+
try {guard();const result=fn(store);if(result && typeof result.then==='function')throw new Error('Delivery transactions must be synchronous');db.exec('COMMIT');return result;}
|
|
43
|
+
catch(error){db.exec('ROLLBACK');throw error;}finally{inTransaction=false;}
|
|
44
|
+
},
|
|
45
|
+
boundary(sessionId){guard();return db.prepare('SELECT boundary FROM sessions WHERE namespace=? AND session_id=?').get(namespace,sessionId)?.boundary??null;},
|
|
46
|
+
setBoundary(sessionId,boundary){writeGuard();db.prepare('INSERT INTO sessions VALUES (?,?,?) ON CONFLICT(namespace,session_id) DO UPDATE SET boundary=excluded.boundary').run(namespace,sessionId,boundary);},
|
|
47
|
+
get(sessionId,clientId){guard();const row=db.prepare('SELECT ordinal,data FROM inputs WHERE namespace=? AND session_id=? AND client_id=?').get(namespace,sessionId,clientId);return row?{...JSON.parse(row.data),ordinal:row.ordinal}:null;},
|
|
48
|
+
list(sessionId){guard();return db.prepare('SELECT ordinal,data FROM inputs WHERE namespace=?'+(sessionId===undefined?'':' AND session_id=?')+' ORDER BY ordinal').all(...(sessionId===undefined?[namespace]:[namespace,sessionId])).map(row=>({...JSON.parse(row.data),ordinal:row.ordinal}));},
|
|
49
|
+
put(entry){
|
|
50
|
+
writeGuard();const {ordinal,...value}=entry;const data=JSON.stringify(value);
|
|
51
|
+
if(Buffer.byteLength(data)>64*1024*1024)throw deliveryError('DELIVERY_TOO_LARGE','Input exceeds the durable storage limit');
|
|
52
|
+
db.prepare('INSERT INTO inputs(namespace,session_id,client_id,data) VALUES (?,?,?,?) ON CONFLICT(namespace,session_id,client_id) DO UPDATE SET data=excluded.data').run(namespace,value.sessionId,value.clientId,data);
|
|
53
|
+
},
|
|
54
|
+
close(){if(closed)return;guard();db.prepare('DELETE FROM owner WHERE singleton=1 AND instance=?').run(owner);db.close();closed=true;},
|
|
55
|
+
};
|
|
56
|
+
return store;
|
|
57
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { deliveryError } from './host-input-delivery-store.js';
|
|
3
|
+
const terminal = phase => phase==='accepted' || phase==='removed';
|
|
4
|
+
function canonical(value) {
|
|
5
|
+
if(Array.isArray(value))return value.map(canonical);
|
|
6
|
+
if(value && typeof value==='object')return Object.fromEntries(Object.keys(value).sort().filter(k=>value[k]!==undefined).map(k=>[k,canonical(value[k])]));
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function inputFingerprint(value){return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');}
|
|
10
|
+
export function deliveryClientIds(options) {
|
|
11
|
+
if(!options || typeof options!=='object' || !('deliveryClientIds' in options))return [];
|
|
12
|
+
const ids=options.deliveryClientIds;
|
|
13
|
+
if(!Array.isArray(ids)||ids.length>64||ids.some(id=>typeof id!=='string'||!id||id.length>256))throw deliveryError('INVALID_PARAMS','deliveryClientIds must contain at most 64 nonempty IDs (256 characters each)');
|
|
14
|
+
return [...new Set(ids)];
|
|
15
|
+
}
|
|
16
|
+
function identity(sessionId,clientId){if(typeof sessionId!=='string'||!sessionId||typeof clientId!=='string'||!clientId||clientId.length>256)throw deliveryError('INVALID_PARAMS','A sessionId and clientId of at most 256 characters are required');}
|
|
17
|
+
function requestShape(row){return Object.fromEntries(['text','persistedContent','files','agentReferences','sessionRefs','origin','sourcePlugin','createOpts'].filter(k=>row[k]!==undefined).map(k=>[k,row[k]]));}
|
|
18
|
+
/** One owner for durable handoff, session command serialization, and permission to retry admission. */
|
|
19
|
+
export function createInputDeliveryCoordinator({store,backend,baseProjection=()=>({pendingQueue:[],steeringQueueClientIds:[]}),onChange=()=>{},onError=()=>{},validateProjection=()=>true,now=()=>Date.now()}){
|
|
20
|
+
const lanes=new Map(), scheduled=new Set();let stopped=false;
|
|
21
|
+
function notify(sid){queueMicrotask(()=>Promise.resolve().then(()=>onChange(sid)).catch(onError));}
|
|
22
|
+
function lane(sid,fn){const previous=lanes.get(sid)??Promise.resolve();const result=previous.catch(()=>{}).then(fn);const tail=result.catch(()=>{}).finally(()=>{if(lanes.get(sid)===tail)lanes.delete(sid);});lanes.set(sid,tail);return result;}
|
|
23
|
+
function save(entry){store.transaction(()=>store.put(entry));}
|
|
24
|
+
function view(sid,ids=[],override){
|
|
25
|
+
const base=override??baseProjection(sid);const pending=(base.pendingQueue??[]).filter(row=>!terminal(store.get(sid,row.clientId)?.phase));
|
|
26
|
+
for(const e of store.list(sid))if(e.phase==='stored' && e.queued){const at=pending.findIndex(p=>p.clientId===e.clientId);if(at<0)pending.push(e.queued);else pending[at]=e.queued;}
|
|
27
|
+
return {...base,sessionId:sid,pendingQueue:pending,inputDeliveryVersion:1,clearBoundaryMs:store.boundary(sid),deliveryReceipts:ids.map(clientId=>{const e=store.get(sid,clientId);return{clientId,state:e?e.phase==='removed'?'removed':e.phase==='accepted'?'accepted':e.payload===undefined?'unknown':'pending':'unknown'};})};
|
|
28
|
+
}
|
|
29
|
+
async function evidence(sid){
|
|
30
|
+
const proof=await backend.evidence(sid);
|
|
31
|
+
if(!proof || !Array.isArray(proof.users) || !Array.isArray(proof.inbox))throw deliveryError('DELIVERY_EVIDENCE_UNAVAILABLE','Durable DSH evidence is unavailable');
|
|
32
|
+
store.transaction(()=>{for(const user of proof.users){const e=store.get(sid,user.clientId);if(e?.phase==='removed')throw deliveryError('DELIVERY_CANCEL_CONFLICT','A sealed cancellation has a durable user message');if(e && e.phase!=='accepted')store.put({...e,phase:'accepted',payload:null,userSeq:user.seq,error:null});}});
|
|
33
|
+
return proof;
|
|
34
|
+
}
|
|
35
|
+
async function runSession(sid){
|
|
36
|
+
if(stopped)return;
|
|
37
|
+
let proof=await evidence(sid);
|
|
38
|
+
for(let current of store.list(sid)){
|
|
39
|
+
if(stopped)return;
|
|
40
|
+
current=store.get(sid,current.clientId);
|
|
41
|
+
if(terminal(current.phase))continue;
|
|
42
|
+
if(current.cancelRequested){await cancelNow(sid,current.clientId,proof);proof=await evidence(sid);continue;}
|
|
43
|
+
if(current.phase!=='stored'){
|
|
44
|
+
if(current.attemptEpoch===store.epoch)continue; // A live-process claim/admission may still settle.
|
|
45
|
+
const held=proof.inbox.find(item=>item.clientId===current.clientId);
|
|
46
|
+
if(held){
|
|
47
|
+
if(typeof backend.recoverColdInbox!=='function'||!await backend.recoverColdInbox(sid,store.list(sid))) {save({...current,error:'DELIVERY_RECOVERY_WAITING'});continue;}
|
|
48
|
+
proof=await evidence(sid);
|
|
49
|
+
if(!proof.running && !proof.inbox.length)store.transaction(()=>{for(const e of store.list(sid))if(!terminal(e.phase)&&e.attemptEpoch!==store.epoch&&!e.cancelRequested)store.put({...e,phase:'stored',error:null});});
|
|
50
|
+
if(proof.inbox.some(item=>item.clientId===current.clientId))throw deliveryError('DELIVERY_RECOVERY_WAITING','Cold inbox occurrence is still owned by DSH');
|
|
51
|
+
}
|
|
52
|
+
if(proof.running){save({...current,error:'DELIVERY_RECOVERY_WAITING'});continue;}
|
|
53
|
+
current=store.get(sid,current.clientId);
|
|
54
|
+
if(terminal(current.phase))continue;
|
|
55
|
+
save({...current,phase:'stored',error:null});
|
|
56
|
+
}
|
|
57
|
+
current=store.get(sid,current.clientId);
|
|
58
|
+
if(stopped || current.cancelRequested || current.phase!=='stored')continue;
|
|
59
|
+
if((current.nextAttemptAt??0)>now())break;
|
|
60
|
+
const attempted={...current,phase:'dispatching',attemptEpoch:store.epoch,attemptPid:store.pid,attemptNo:(current.attemptNo??0)+1,error:null};save(attempted);
|
|
61
|
+
try {
|
|
62
|
+
await backend.dispatch(attempted);
|
|
63
|
+
proof=await evidence(sid);
|
|
64
|
+
const settled=store.get(sid,current.clientId);
|
|
65
|
+
if(!terminal(settled.phase))save({...settled,phase:proof.inbox.some(item=>item.clientId===current.clientId)?'DSH-pending':'claimed',error:null});
|
|
66
|
+
}catch(error){const e=store.get(sid,current.clientId);if(!terminal(e.phase))save({...e,...(error.deliveryNotAdmitted?{phase:'stored',...(error.deliveryRetryMode?{payload:{...e.payload,mode:error.deliveryRetryMode}}:{}),nextAttemptAt:now()+Math.min(30000,1000*2**Math.min(e.attemptNo??1,5))}:{}),error:String(error?.code??'DELIVERY_ADMISSION_UNCERTAIN')});onError(error);if(error.deliveryNotAdmitted){notify(sid);break;}}
|
|
67
|
+
notify(sid);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function schedule(sid){if(stopped||scheduled.has(sid))return;scheduled.add(sid);setImmediate(()=>{scheduled.delete(sid);if(!stopped)void lane(sid,()=>runSession(sid)).catch(onError);});}
|
|
71
|
+
async function cancelNow(sid,cid,initial){
|
|
72
|
+
let e=store.get(sid,cid);if(e.phase==='removed')return true;
|
|
73
|
+
const proof=initial??await evidence(sid);e=store.get(sid,cid);
|
|
74
|
+
if(e.phase==='accepted'||proof.users.some(u=>u.clientId===cid))return false;
|
|
75
|
+
const held=proof.inbox.find(item=>item.clientId===cid);
|
|
76
|
+
if(held){if(e.payload===undefined)throw deliveryError('NOT_AVAILABLE','A foreign DSH input cannot be sealed by this delivery owner');try{await backend.remove(sid,held.id);}catch(error){if(error.deliveryRemovalApplied)save({...store.get(sid,cid),removalApplied:true});throw error;}const after=await evidence(sid);e=store.get(sid,cid);if(e.phase==='accepted'||after.users.some(u=>u.clientId===cid))return false;if(after.inbox.some(item=>item.clientId===cid))throw deliveryError('DELIVERY_CANCEL_UNCERTAIN','Input is still in the durable inbox');}
|
|
77
|
+
else if(e.phase!=='stored' && !e.removalApplied && e.attemptEpoch===store.epoch)return false; // Claimed or admission still capable of arriving.
|
|
78
|
+
else if(e.phase!=='stored' && proof.running)throw deliveryError('DELIVERY_CANCEL_UNCERTAIN','Cannot seal a potentially live input');
|
|
79
|
+
save({...store.get(sid,cid),phase:'removed',payload:null,cancelRequested:false,error:null});notify(sid);return true;
|
|
80
|
+
}
|
|
81
|
+
const api={
|
|
82
|
+
currentProjection:(sid,ids=[],base)=>view(sid,ids,base),
|
|
83
|
+
owns:(sid,cid)=>store.get(sid,cid)!==null,
|
|
84
|
+
async accept(sid,row,options={}){
|
|
85
|
+
identity(sid,row?.clientId);if(stopped)throw deliveryError('DELIVERY_STOPPED','Input delivery is stopping');
|
|
86
|
+
return lane(sid,async()=>{
|
|
87
|
+
if(stopped)throw deliveryError('DELIVERY_STOPPED','Input delivery is stopping');
|
|
88
|
+
let e=store.get(sid,row.clientId);const raw=inputFingerprint(requestShape(row));
|
|
89
|
+
if(e?.phase==='removed')return view(sid,[row.clientId]);
|
|
90
|
+
if(e && e.rawFingerprint===raw){schedule(sid);return view(sid,[row.clientId]);}
|
|
91
|
+
const hasBoundary=Object.hasOwn(options,'expectedClearBoundaryMs');
|
|
92
|
+
if(row.durableDelivery===true && !hasBoundary)throw deliveryError('INVALID_PARAMS','Durable input requires expectedClearBoundaryMs, including explicit null');
|
|
93
|
+
const expectedBoundary=hasBoundary?options.expectedClearBoundaryMs:store.boundary(sid);
|
|
94
|
+
if(!e && expectedBoundary!==store.boundary(sid))throw deliveryError('REMOTE_OPTIMISTIC_INPUT_CLEARED','REMOTE_OPTIMISTIC_INPUT_CLEARED: input clear boundary changed');
|
|
95
|
+
const prepared=await backend.prepare(sid,row,options);
|
|
96
|
+
if(!prepared || prepared.payload===undefined || !prepared.queued)throw deliveryError('DELIVERY_PREPARATION_FAILED','Host did not obtain complete replayable input');
|
|
97
|
+
const {consumed: _transit,mode: _mode,...semanticPayload}=prepared.payload;const fingerprint=inputFingerprint(semanticPayload);e=store.get(sid,row.clientId);
|
|
98
|
+
if(e){if(e.phase==='removed')return view(sid,[row.clientId]);if(e.fingerprint!==fingerprint)throw deliveryError('INPUT_DELIVERY_CONFLICT','clientId already belongs to a different input');return view(sid,[row.clientId]);}
|
|
99
|
+
if(expectedBoundary!==store.boundary(sid))throw deliveryError('REMOTE_OPTIMISTIC_INPUT_CLEARED','REMOTE_OPTIMISTIC_INPUT_CLEARED: input was cleared during preparation');
|
|
100
|
+
const preview=view(sid,[row.clientId]);preview.pendingQueue=[...preview.pendingQueue,prepared.queued];preview.deliveryReceipts=[{clientId:row.clientId,state:'pending'}];
|
|
101
|
+
if(!validateProjection(preview))throw deliveryError('DELIVERY_TOO_LARGE','Input queue projection cannot fit the negotiated transport budget');
|
|
102
|
+
save({sessionId:sid,clientId:row.clientId,phase:'stored',request:requestShape(row),rawFingerprint:raw,fingerprint,payload:prepared.payload,queued:prepared.queued,clearBoundaryMs:store.boundary(sid),sendAtMs:options.sendAtMs??null,createdAt:now(),attemptNo:0,cancelRequested:false});
|
|
103
|
+
notify(sid);schedule(sid);return view(sid,[row.clientId]);
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
async edit(sid,cid,value){
|
|
107
|
+
identity(sid,cid);
|
|
108
|
+
return lane(sid,async()=>{
|
|
109
|
+
let entry=store.get(sid,cid);if(!entry?.payload||terminal(entry.phase)||entry.cancelRequested)throw deliveryError('NOT_FOUND','Input is no longer editable');
|
|
110
|
+
const text=typeof value==='string'?value:value?.text;if(typeof text!=='string'||!text.trim())throw deliveryError('INVALID_PARAMS','Edited input needs text');
|
|
111
|
+
const candidate={...entry.queued,...(typeof value==='object'?value:{}),clientId:cid,text,files:typeof value==='object'&&Array.isArray(value.files)?value.files:entry.queued.files};
|
|
112
|
+
const filesChanged=inputFingerprint(candidate.files??[])!==inputFingerprint(entry.queued.files??[]);
|
|
113
|
+
const prepared=filesChanged?await backend.prepare(sid,candidate,{}):{payload:{...entry.payload,text,mode:'queue'},queued:{...entry.queued,text,persistedContent:typeof value==='object'&&typeof value.persistedContent==='string'?value.persistedContent:text,chatMessage:{...entry.queued.chatMessage,content:text}}};
|
|
114
|
+
entry=store.get(sid,cid);if(entry.cancelRequested||entry.clearBoundaryMs!==store.boundary(sid))throw deliveryError('REMOTE_OPTIMISTIC_INPUT_CLEARED','Input changed ownership during edit');
|
|
115
|
+
const proof=await evidence(sid);const held=proof.inbox.find(item=>item.clientId===cid);
|
|
116
|
+
if(held){if(held.placement==='next-step')throw deliveryError('NOT_AVAILABLE','An active steering input cannot be edited');await backend.remove(sid,held.id);const after=await evidence(sid);if(after.users.some(u=>u.clientId===cid)||after.inbox.some(item=>item.clientId===cid))throw deliveryError('DELIVERY_EDIT_UNCERTAIN','Input changed while editing');}
|
|
117
|
+
else if(entry.phase!=='stored')throw deliveryError('NOT_FOUND','Input was already claimed');
|
|
118
|
+
entry=store.get(sid,cid);if(terminal(entry.phase)||entry.cancelRequested)throw deliveryError('NOT_FOUND','Input changed while editing');
|
|
119
|
+
const {consumed,mode,...semantic}=prepared.payload;save({...entry,phase:'stored',request:requestShape(candidate),rawFingerprint:inputFingerprint(requestShape(candidate)),fingerprint:inputFingerprint(semantic),payload:prepared.payload,queued:prepared.queued,nextAttemptAt:0,error:null});
|
|
120
|
+
notify(sid);schedule(sid);return view(sid,[cid]);
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
async steer(sid,row,options={}){
|
|
124
|
+
identity(sid,row?.clientId);
|
|
125
|
+
const promoted=await lane(sid,async()=>{
|
|
126
|
+
const proof=await evidence(sid);const held=proof.inbox.find(x=>x.clientId===row.clientId);const entry=store.get(sid,row.clientId);
|
|
127
|
+
if(held){if(!entry?.payload)throw deliveryError('NOT_AVAILABLE','This input has no Host-owned replay payload for safe promotion');await backend.promote(sid,held.id);await evidence(sid);notify(sid);return true;}
|
|
128
|
+
if(entry)return entry.phase==='removed'?false:true;
|
|
129
|
+
return null;
|
|
130
|
+
});
|
|
131
|
+
if(promoted!==null)return promoted;
|
|
132
|
+
await api.accept(sid,row,{...options,mode:'steer'});return true;
|
|
133
|
+
},
|
|
134
|
+
async projection(sid,options){
|
|
135
|
+
if(typeof sid!=='string'||!sid)throw deliveryError('INVALID_PARAMS','sessionId is required');const ids=deliveryClientIds(options);
|
|
136
|
+
const proof=await evidence(sid);
|
|
137
|
+
// Actual durable user history is authoritative even for an input not submitted through this instance.
|
|
138
|
+
store.transaction(()=>{for(const cid of ids)if(!store.get(sid,cid)&&proof.users.some(u=>u.clientId===cid))store.put({sessionId:sid,clientId:cid,phase:'accepted',userSeq:proof.users.find(u=>u.clientId===cid).seq});});
|
|
139
|
+
schedule(sid);return view(sid,ids);
|
|
140
|
+
},
|
|
141
|
+
async cancel(sid,cid){
|
|
142
|
+
identity(sid,cid);if(stopped)throw deliveryError('DELIVERY_STOPPED','Input delivery is stopping');
|
|
143
|
+
// Persist intent now, including cancel-before-enqueue, even while another admission awaits.
|
|
144
|
+
store.transaction(()=>{const e=store.get(sid,cid);if(!e)store.put({sessionId:sid,clientId:cid,phase:'stored',cancelRequested:true,createdAt:now()});else if(!terminal(e.phase))store.put({...e,cancelRequested:true});});
|
|
145
|
+
return lane(sid,async()=>{const cancelled=await cancelNow(sid,cid);return {...view(sid,[cid]),inputDeliveryCancelled:cancelled};});
|
|
146
|
+
},
|
|
147
|
+
async clear(sid){
|
|
148
|
+
if(stopped)throw deliveryError('DELIVERY_STOPPED','Input delivery is stopping');
|
|
149
|
+
store.transaction(()=>{const previous=store.boundary(sid);store.setBoundary(sid,Math.max(now(),previous===null?0:previous+1));for(const e of store.list(sid))if(!terminal(e.phase))store.put({...e,cancelRequested:true});});
|
|
150
|
+
return lane(sid,async()=>{const proof=await evidence(sid);store.transaction(()=>{for(const item of proof.inbox)if(!store.get(sid,item.clientId))store.put({sessionId:sid,clientId:item.clientId,phase:'stored',cancelRequested:true,createdAt:now()});});for(const e of store.list(sid))if(e.cancelRequested)await cancelNow(sid,e.clientId);notify(sid);return view(sid);});
|
|
151
|
+
},
|
|
152
|
+
async drain(){while(scheduled.size||lanes.size){await new Promise(resolve=>setImmediate(resolve));await Promise.all([...lanes.values()]);}},
|
|
153
|
+
recover(){for(const sid of new Set(store.list().filter(e=>!terminal(e.phase)).map(e=>e.sessionId)))schedule(sid);},
|
|
154
|
+
diagnostics(){return store.list().filter(e=>!terminal(e.phase)).map(e=>({sessionId:e.sessionId,clientId:e.clientId,phase:e.phase,error:e.error??null}));},
|
|
155
|
+
async close(){stopped=true;await Promise.all([...lanes.values()]);store.close();},
|
|
156
|
+
};
|
|
157
|
+
return api;
|
|
158
|
+
}
|
package/src/host-version.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Cindy
|
|
2
|
-
*
|
|
3
|
-
*
|
|
1
|
+
/** Conservative Cindy handshake identity, NOT this plugin release or feature negotiation.
|
|
2
|
+
* Modern input support is explicitly negotiated with inputDeliveryVersion: 1.
|
|
3
|
+
* Do not impersonate a native Cindy app version and implicitly enable unrelated features.
|
|
4
4
|
*/
|
|
5
5
|
export const HOST_APP_VERSION = '0.1.82';
|
|
6
6
|
/** Actual npm release, separate from the Cindy compatibility identity. */
|
|
7
|
-
export const HOST_PLUGIN_VERSION = '0.1.
|
|
7
|
+
export const HOST_PLUGIN_VERSION = '0.1.27';
|
package/src/host.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* missing `pong`s as a dead socket.
|
|
20
20
|
*/
|
|
21
21
|
import WebSocket from 'ws';
|
|
22
|
+
import { join as joinDeliveryPath } from 'node:path';
|
|
23
|
+
import { createHash as deliveryHash } from 'node:crypto';
|
|
24
|
+
import { openInputDeliveryStore, deliveryError } from './host-input-delivery-store.js';
|
|
25
|
+
import { createInputDeliveryCoordinator } from './host-input-delivery.js';
|
|
22
26
|
import { realpath as realpathImpl, stat as statImpl } from 'node:fs/promises';
|
|
23
27
|
import { extname as extnameImpl, resolve as resolvePathImpl } from 'node:path';
|
|
24
28
|
import { restoreSession } from './auth-session.js';
|
|
@@ -49,7 +53,7 @@ import { createExportOwner } from './host-export-owner.js';
|
|
|
49
53
|
import { createTransferDiagnostics } from './host-transfer-diagnostics.js';
|
|
50
54
|
import { normalizeAuthBaseUrl } from './cindy-login-flow.js';
|
|
51
55
|
import { RECONNECT_STABLE_RESET_MS, computeReconnectDelayMs } from './host-reconnect.js';
|
|
52
|
-
import { MAX_FRAME_BYTES, fitInvokeResultToFrame, frameByteLength } from './host-frame-budget.js';
|
|
56
|
+
import { MAX_FRAME_BYTES, FRAME_BUDGET_BYTES, fitInvokeResultToFrame, frameByteLength } from './host-frame-budget.js';
|
|
53
57
|
|
|
54
58
|
/** Mainland DeviceLink relay. The settings card never guesses this value. */
|
|
55
59
|
export const RELAY_WS_URL = 'wss://device-link.cindy.com.cn/api/device-link/ws';
|
|
@@ -1054,6 +1058,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1054
1058
|
* @param payload - the channel's payload.
|
|
1055
1059
|
*/
|
|
1056
1060
|
function pushSessionUpdate(sessionId, channel, payload) {
|
|
1061
|
+
if (channel === 'maker:input:projection' && activeDelivery) payload = activeDelivery.currentProjection(sessionId, [], payload);
|
|
1057
1062
|
const devices = sessionSubscribers.get(sessionId);
|
|
1058
1063
|
const watchers = devices === undefined ? 0 : devices.size;
|
|
1059
1064
|
const reachable = devices === undefined ? 0 : [...devices].filter((deviceId) => !offlineDevices.has(deviceId)).length;
|
|
@@ -1120,6 +1125,11 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1120
1125
|
* chip instead of the picture.
|
|
1121
1126
|
*/
|
|
1122
1127
|
async function pushSessionMessage(sessionId, message) {
|
|
1128
|
+
if (message?.role === 'user' && activeDelivery?.owns(sessionId, message.clientId)) {
|
|
1129
|
+
await new Promise(resolve => queueMicrotask(resolve));
|
|
1130
|
+
const proof = await activeDelivery.projection(sessionId, { deliveryClientIds: [message.clientId] });
|
|
1131
|
+
if (proof.deliveryReceipts[0]?.state !== 'accepted') return;
|
|
1132
|
+
}
|
|
1123
1133
|
try {
|
|
1124
1134
|
// `options.resolveCapabilities` is the plugin's provider — the only source of the hydrator.
|
|
1125
1135
|
// (Reading it through the router's own `resolveCapabilities` is not possible from here: that
|
|
@@ -1160,6 +1170,56 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1160
1170
|
return sessionRowCache.get(sessionId);
|
|
1161
1171
|
}
|
|
1162
1172
|
|
|
1173
|
+
let deliveryNamespace = options.inputDeliveryNamespace ?? null;
|
|
1174
|
+
var activeDelivery = null;
|
|
1175
|
+
const deliveryInstances = new Set();
|
|
1176
|
+
let deliveryLastError = null;
|
|
1177
|
+
function getInputDelivery() {
|
|
1178
|
+
if (!options.inputDeliveryDirectory) return null;
|
|
1179
|
+
if (!deliveryNamespace) throw deliveryError('DELIVERY_NOT_AVAILABLE', 'Waiting for the authenticated Host account');
|
|
1180
|
+
if (activeDelivery) return activeDelivery;
|
|
1181
|
+
const namespace = deliveryNamespace;
|
|
1182
|
+
const currentBackend = () => {
|
|
1183
|
+
const backend = options.resolveCapabilities?.().inputDeliveryBackend;
|
|
1184
|
+
if (!backend || !backend.available()) throw deliveryError('DELIVERY_NOT_AVAILABLE', 'Durable DSH delivery backend is unavailable');
|
|
1185
|
+
return backend;
|
|
1186
|
+
};
|
|
1187
|
+
currentBackend();
|
|
1188
|
+
const file = joinDeliveryPath(options.inputDeliveryDirectory, deliveryHash('sha256').update(namespace).digest('hex') + '.sqlite');
|
|
1189
|
+
const store = openInputDeliveryStore({ file, namespace });
|
|
1190
|
+
const coordinator = createInputDeliveryCoordinator({
|
|
1191
|
+
store,
|
|
1192
|
+
backend: {
|
|
1193
|
+
prepare: (sid, row, opts) => currentBackend().prepare(sid, row, { ...opts, resolveAttachmentRef }),
|
|
1194
|
+
evidence: sid => currentBackend().evidence(sid),
|
|
1195
|
+
dispatch: entry => currentBackend().dispatch(entry, { releaseAttachmentRef: key => deliveryNamespace === namespace ? releaseAttachmentRef(key) : Promise.resolve() }),
|
|
1196
|
+
remove: (sid, id) => currentBackend().remove(sid, id),
|
|
1197
|
+
promote: (sid, id) => currentBackend().promote(sid, id),
|
|
1198
|
+
recoverColdInbox: (sid, entries) => currentBackend().recoverColdInbox(sid, entries),
|
|
1199
|
+
},
|
|
1200
|
+
baseProjection: sid => inputQueue.projectionFor(sid, sessionRowFor(sid)),
|
|
1201
|
+
validateProjection: projection => frameByteLength({ v: 1, kind: 'invoke-result', id: 'delivery', dst: 'controller', payload: { ok: true, result: projection } }) <= FRAME_BUDGET_BYTES - 24576,
|
|
1202
|
+
onChange: sid => {
|
|
1203
|
+
if (activeDelivery !== coordinator || deliveryNamespace !== namespace || stopped) return;
|
|
1204
|
+
pushSessionUpdate(sid, 'maker:input:projection', coordinator.currentProjection(sid));
|
|
1205
|
+
pushHistoryViewChanged(sid);
|
|
1206
|
+
},
|
|
1207
|
+
onError: error => { deliveryLastError = { at: new Date().toISOString(), code: error?.code ?? 'DELIVERY_FAILURE', message: String(error?.message ?? error).slice(0, 300) }; },
|
|
1208
|
+
});
|
|
1209
|
+
deliveryInstances.add(coordinator);
|
|
1210
|
+
activeDelivery = coordinator;
|
|
1211
|
+
coordinator.recover();
|
|
1212
|
+
return coordinator;
|
|
1213
|
+
}
|
|
1214
|
+
function bindDeliveryNamespace(namespace) {
|
|
1215
|
+
if (deliveryNamespace === namespace) return;
|
|
1216
|
+
deliveryNamespace = namespace;
|
|
1217
|
+
const previous = activeDelivery; activeDelivery = null;
|
|
1218
|
+
if (previous) void previous.close().then(() => deliveryInstances.delete(previous)).catch(error => recordHandlerError('input-delivery-close', error));
|
|
1219
|
+
}
|
|
1220
|
+
const deliveryTimer = options.inputDeliveryDirectory ? setInterval(() => { if (!stopped) activeDelivery?.recover(); }, 1000) : null;
|
|
1221
|
+
deliveryTimer?.unref?.();
|
|
1222
|
+
|
|
1163
1223
|
const routeInvoke = createChannelRouter({
|
|
1164
1224
|
getSession: async (sessionId) => {
|
|
1165
1225
|
if (source == null) throw new Error('no DSH session source is attached');
|
|
@@ -1230,7 +1290,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1230
1290
|
// plugin owns the attachment reader, and a row must be renderable in the frame that
|
|
1231
1291
|
// carries it (see `pushSessionMessage`).
|
|
1232
1292
|
hydrateImages: provided.hydrateImages,
|
|
1233
|
-
|
|
1293
|
+
inputDelivery: getInputDelivery,
|
|
1294
|
+
inputProjection: (sessionId, pending) => { const base = inputQueue.projectionFor(sessionId, sessionRowFor(sessionId), pending ?? null); return activeDelivery ? activeDelivery.currentProjection(sessionId, [], base) : base; },
|
|
1234
1295
|
queuedRow: ({ clientId, text, sessionId }) => queuedRowFromController({ clientId, text, session: sessionRowFor(sessionId) }),
|
|
1235
1296
|
goalStatus: (sessionId) => toGoalStatusPayload(sessionId, projections.goalOf(sessionId)),
|
|
1236
1297
|
/**
|
|
@@ -1917,6 +1978,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1917
1978
|
userId: typeof payload.userId === 'string' ? payload.userId : null,
|
|
1918
1979
|
protocolVersion: typeof payload.serverProtocolVersion === 'number' ? payload.serverProtocolVersion : null,
|
|
1919
1980
|
});
|
|
1981
|
+
if (typeof payload.userId === 'string' && typeof payload.deviceId === 'string') bindDeliveryNamespace([mediaBase, normalizeAuthBaseUrl(socketSession?.authBaseUrl), payload.userId, payload.deviceId].join('|'));
|
|
1920
1982
|
status.setState('waiting');
|
|
1921
1983
|
syncDeviceName();
|
|
1922
1984
|
// The relay itself accepted us, which is the first moment "online" is true —
|
|
@@ -2295,6 +2357,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2295
2357
|
active = false;
|
|
2296
2358
|
await disconnect();
|
|
2297
2359
|
await fileExports.stop();
|
|
2360
|
+
if (deliveryTimer !== null) clearInterval(deliveryTimer);
|
|
2361
|
+
await Promise.all([...deliveryInstances].map(coordinator => coordinator.close()));
|
|
2362
|
+
deliveryInstances.clear(); activeDelivery = null;
|
|
2298
2363
|
status.setState('disconnected');
|
|
2299
2364
|
}
|
|
2300
2365
|
|
|
@@ -2307,7 +2372,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2307
2372
|
acceptedControllers,
|
|
2308
2373
|
policy,
|
|
2309
2374
|
status,
|
|
2310
|
-
getStatus: () => ({ ...status.snapshot(), pluginVersion: HOST_PLUGIN_VERSION, nameSync: deviceNameSync.snapshot() }),
|
|
2375
|
+
getStatus: () => ({ ...status.snapshot(), pluginVersion: HOST_PLUGIN_VERSION, nameSync: deviceNameSync.snapshot(), inputDelivery: { ready: activeDelivery !== null, version: activeDelivery ? 1 : null, pending: activeDelivery?.diagnostics().slice(0, 30) ?? [], lastError: deliveryLastError } }),
|
|
2311
2376
|
deleteDevice,
|
|
2312
2377
|
subscribeStatus: (listener) => status.subscribe(listener),
|
|
2313
2378
|
/** The most recent invokes this Host served, for the status route's diagnostics. */
|