shennian 0.3.85 → 0.3.86

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.
Files changed (99) hide show
  1. package/dist/bin/shennian.js +1 -1
  2. package/dist/publish-build-manifest.json +28 -28
  3. package/dist/src/agents/adapter.js +19 -1
  4. package/dist/src/agents/claude-managed-permissions.js +248 -1
  5. package/dist/src/agents/claude.js +393 -8
  6. package/dist/src/agents/codex-managed-permissions.js +294 -1
  7. package/dist/src/agents/codex-managed-room-tools.js +249 -1
  8. package/dist/src/agents/codex-utils.js +200 -7
  9. package/dist/src/agents/codex.js +1352 -17
  10. package/dist/src/agents/config-status.js +226 -1
  11. package/dist/src/agents/cursor-managed-permissions.js +299 -2
  12. package/dist/src/agents/cursor.js +314 -1
  13. package/dist/src/agents/custom.js +418 -4
  14. package/dist/src/agents/external-channel-instructions.js +94 -11
  15. package/dist/src/agents/gemini.js +180 -1
  16. package/dist/src/agents/manager.d.ts +2 -0
  17. package/dist/src/agents/manager.js +250 -18
  18. package/dist/src/agents/model-registry/cache.js +37 -1
  19. package/dist/src/agents/model-registry/discovery.js +188 -2
  20. package/dist/src/agents/model-registry/parsers.js +521 -4
  21. package/dist/src/agents/model-registry/runner.js +30 -1
  22. package/dist/src/agents/model-registry/service.js +115 -1
  23. package/dist/src/agents/model-registry/types.js +8 -1
  24. package/dist/src/agents/model-registry.js +19 -1
  25. package/dist/src/agents/native-capabilities.js +237 -1
  26. package/dist/src/agents/openclaw.js +293 -2
  27. package/dist/src/agents/opencode-managed-permissions.js +187 -2
  28. package/dist/src/agents/opencode.js +298 -1
  29. package/dist/src/agents/platform-instructions.js +46 -17
  30. package/dist/src/agents/runtime-compatibility.js +44 -1
  31. package/dist/src/agents/workbuddy-managed-permissions.js +329 -2
  32. package/dist/src/agents/workbuddy.js +444 -3
  33. package/dist/src/commands/room.js +2 -2
  34. package/dist/src/fs/boundary.js +185 -1
  35. package/dist/src/fs/handler.js +130 -1
  36. package/dist/src/fs/security.js +32 -1
  37. package/dist/src/fs/text-decoder.js +110 -1
  38. package/dist/src/index.js +587 -3
  39. package/dist/src/manager/prompt.js +34 -29
  40. package/dist/src/manager/registry.js +317 -1
  41. package/dist/src/manager/retired-external-channel-compat.js +40 -1
  42. package/dist/src/manager/runtime.d.ts +4 -0
  43. package/dist/src/manager/runtime.js +1514 -23
  44. package/dist/src/native-fusion/codex-parser.js +660 -4
  45. package/dist/src/native-fusion/codex-user-input.js +102 -5
  46. package/dist/src/native-fusion/config.js +5 -1
  47. package/dist/src/native-fusion/file-watcher.js +191 -1
  48. package/dist/src/native-fusion/local-repository.js +569 -2
  49. package/dist/src/native-fusion/opencode-parser.js +130 -3
  50. package/dist/src/native-fusion/parsers.js +140 -3
  51. package/dist/src/native-fusion/service.js +739 -2
  52. package/dist/src/native-fusion/session-header.js +101 -2
  53. package/dist/src/native-fusion/state.js +104 -2
  54. package/dist/src/native-fusion/types.js +1 -1
  55. package/dist/src/personal-sync/machine-authority.js +34 -1
  56. package/dist/src/room/activation-audit.js +45 -2
  57. package/dist/src/room/activation-policy.js +30 -1
  58. package/dist/src/room/activation-service.d.ts +1 -0
  59. package/dist/src/room/activation-service.js +408 -8
  60. package/dist/src/room/invite-bootstrap.js +156 -1
  61. package/dist/src/room/managed-context.d.ts +1 -1
  62. package/dist/src/room/managed-context.js +1 -1
  63. package/dist/src/room/managed-prompt-composer.d.ts +1 -1
  64. package/dist/src/room/managed-prompt-composer.js +6 -5
  65. package/dist/src/room/managed-room-binding.js +167 -1
  66. package/dist/src/room/managed-room-ipc.d.ts +2 -2
  67. package/dist/src/room/managed-room-ipc.js +2 -2
  68. package/dist/src/room/mirror-sync-service.js +183 -1
  69. package/dist/src/room/room-action-client.d.ts +11 -0
  70. package/dist/src/room/room-action-client.js +1 -1
  71. package/dist/src/room/room-binding-client.d.ts +1 -0
  72. package/dist/src/room/room-binding-client.js +151 -1
  73. package/dist/src/security/managed-agent-policy.d.ts +3 -2
  74. package/dist/src/security/managed-agent-policy.js +1 -1
  75. package/dist/src/session/archive-zip.js +220 -1
  76. package/dist/src/session/deletion-sync-state.js +136 -2
  77. package/dist/src/session/handlers/agent-capabilities.js +14 -1
  78. package/dist/src/session/handlers/agent-config.js +150 -1
  79. package/dist/src/session/handlers/agents.js +55 -1
  80. package/dist/src/session/handlers/chat.d.ts +1 -1
  81. package/dist/src/session/handlers/chat.js +1030 -2
  82. package/dist/src/session/handlers/control.js +61 -1
  83. package/dist/src/session/handlers/fs.js +594 -1
  84. package/dist/src/session/handlers/message-status.js +15 -1
  85. package/dist/src/session/handlers/remote-access.js +43 -1
  86. package/dist/src/session/handlers/room-managed.js +101 -1
  87. package/dist/src/session/handlers/session-refresh.js +35 -1
  88. package/dist/src/session/handlers/title.js +79 -1
  89. package/dist/src/session/handlers/tool-detail.js +149 -1
  90. package/dist/src/session/handlers/voice-window.js +62 -1
  91. package/dist/src/session/history-subscriptions.js +470 -1
  92. package/dist/src/session/manager.js +658 -1
  93. package/dist/src/session/native-cleanup-outbox.js +287 -2
  94. package/dist/src/session/native-session-lifecycle.js +327 -6
  95. package/dist/src/session/queue.d.ts +5 -0
  96. package/dist/src/session/queue.js +8 -8
  97. package/dist/src/upgrade/scheduler.js +126 -1
  98. package/node_modules/@shennian/wire/dist/agent-workspace.js +1 -1
  99. package/package.json +1 -1
@@ -1 +1,470 @@
1
- import{SESSION_HISTORY_LIMITS as l}from"@shennian/wire";import{getCanonicalSessionStore as M,listSessionRecords as _}from"./store.js";import{recordPersonalSyncLocalMetric as d,setPersonalSyncLocalSubscriptions as I}from"../personal-sync/metrics.js";import{assertPersonalSyncIdentity as p,getPersonalSyncIdentity as u}from"../personal-sync/epoch.js";const y=100,b=l.maxDeltaMutations,g=l.maxMutationBytes-4096,m=l.maxReplicaPageBytes-16384;class O{client;store;hasLocalSession;subscriptions=new Map;replicaStreams=new Map;unsubscribeStore;constructor(s,e=M(),i=t=>_().some(n=>n.sessionId===t)){this.client=s,this.store=e,this.hasLocalSession=i,this.unsubscribeStore=e.subscribe(t=>this.handleMutation(t))}handleOpen(s){const e=s.params;if(p(e),h(e.sessionId,e.subscriptionId),!this.hasLocalSession(e.sessionId)&&!this.store.getManifest(e.sessionId))throw new Error("history_unavailable");if(this.subscriptions.has(e.subscriptionId))throw new Error("subscription already exists");if([...this.subscriptions.values()].filter(n=>n.sessionId===e.sessionId).length>=l.maxSubscriptionsPerSession){this.client.sendRes({type:"res",id:s.id,ok:!1,error:"subscription_limit_exceeded",rejection:{code:"subscription_limit_exceeded",retryable:!0,requiredAction:"retry"}});return}const t={sessionId:e.sessionId,subscriptionId:e.subscriptionId,snapshotRevision:0,lastSentRevision:0,active:!1,buffered:[],pending:[],pendingBytes:0,flushTimer:null,lastQueuedRevision:0};this.subscriptions.set(e.subscriptionId,t),this.updateSubscriptionMetric();try{const n=this.store.readPage(e.sessionId,{limit:R(e.limit,l.defaultSnapshotMessages),...e.beforeSequence===void 0?{}:{beforeSequence:e.beforeSequence}});t.snapshotRevision=n.bodyRevision,t.lastSentRevision=n.bodyRevision,t.lastQueuedRevision=n.bodyRevision,this.client.sendRes({type:"res",id:s.id,ok:!0,payload:{...u(this.store.daemonInstallationId),sessionId:e.sessionId,subscriptionId:e.subscriptionId,storageMode:"machine_local",snapshotRevision:n.bodyRevision,firstAvailableSeq:n.firstAvailableSeq,lastAvailableSeq:n.lastAvailableSeq,hasOlder:n.hasOlder,messages:n.entries}}),d("historySnapshots"),d("historyBytesServed",Buffer.byteLength(JSON.stringify(n.entries),"utf8")),t.active=!0;for(const o of t.buffered)this.queueMutation(t,o);t.buffered=[]}catch(n){throw this.subscriptions.delete(e.subscriptionId),this.updateSubscriptionMetric(),n}}handleSync(s){const e=s.params;if(p(e),h(e.sessionId,e.syncId),!Number.isSafeInteger(e.afterRevision)||e.afterRevision<0)throw new Error("invalid history sync revision");if(!this.hasLocalSession(e.sessionId)&&!this.store.getManifest(e.sessionId))throw new Error("history_unavailable");const i=this.store.getManifest(e.sessionId)?.bodyRevision??0;if(e.afterRevision>i)throw new Error("revision_gap");const t=T(e.limit),n=this.store.readDeltas(e.sessionId,e.afterRevision,t),o=[];let r=0;for(const B of n){const S={...B,subscriptionId:e.syncId},f=Buffer.byteLength(JSON.stringify(S),"utf8")+1;if(o.length>0&&r+f>m)break;if(f>m){this.client.sendRes({type:"res",id:s.id,ok:!1,error:"response_too_large"});return}o.push(S),r+=f}const c=o.at(-1)?.revision??e.afterRevision;this.client.sendRes({type:"res",id:s.id,ok:!0,payload:{...u(this.store.daemonInstallationId),sessionId:e.sessionId,syncId:e.syncId,fromRevision:e.afterRevision,toRevision:c,targetRevision:i,hasMore:c<i,mutations:o}}),d("historyDeltas"),d("historyBytesServed",r)}handleReplicaSubscribe(s){const e=s.params;if(p(e),v(e.appInstallationId,e.streamId,e.generation),!this.replicaStreams.has(e.streamId)&&this.replicaStreams.size>=l.maxReplicaStreamsPerDaemon){this.client.sendRes({type:"res",id:s.id,ok:!1,error:"subscription_limit_exceeded",rejection:{code:"subscription_limit_exceeded",retryable:!0,requiredAction:"retry"}});return}const i=this.replicaStreams.get(e.streamId);if(i&&e.generation<i.generation)throw new Error("invalid body replica generation");i&&this.flushReplicaStream(i);const t={appInstallationId:e.appInstallationId,streamId:e.streamId,generation:e.generation,active:!1,buffered:[],highWaterBySession:new Map,lastSentBySession:new Map,lastQueuedBySession:new Map,pendingBySession:new Map,pendingCount:0,pendingBytes:0,flushTimer:null};this.replicaStreams.set(e.streamId,t),this.updateSubscriptionMetric();const n=this.store.listSessionIds().map(o=>{const r=this.store.getManifest(o)?.bodyRevision??0;return t.highWaterBySession.set(o,r),t.lastSentBySession.set(o,r),t.lastQueuedBySession.set(o,r),{sessionId:o,bodyRevision:r}});this.client.sendRes({type:"res",id:s.id,ok:!0,payload:{...u(this.store.daemonInstallationId),appInstallationId:e.appInstallationId,streamId:e.streamId,generation:e.generation,highWatermarks:n}}),t.active=!0;for(const o of t.buffered)this.queueReplicaMutation(t,o);t.buffered=[]}handleReplicaUnsubscribe(s){const e=s.params;p(e),v(e.appInstallationId,e.streamId,e.generation);const i=this.replicaStreams.get(e.streamId);i&&i.generation===e.generation&&(this.flushReplicaStream(i),this.replicaStreams.delete(e.streamId),this.updateSubscriptionMetric()),this.client.sendRes({type:"res",id:s.id,ok:!0,payload:{...u(this.store.daemonInstallationId),appInstallationId:e.appInstallationId,streamId:e.streamId,generation:e.generation,closed:!0}})}handlePage(s){const e=s.params;p(e);const i=this.requireSubscription(e.sessionId,e.subscriptionId);let t;try{t=this.store.readPageAtRevision(e.sessionId,{beforeSequence:e.beforeSequence,limit:R(e.limit,l.defaultPageMessages),revision:i.snapshotRevision})}catch{throw new Error("snapshot_compacted")}this.client.sendRes({type:"res",id:s.id,ok:!0,payload:{...u(this.store.daemonInstallationId),sessionId:e.sessionId,subscriptionId:e.subscriptionId,snapshotRevision:i.snapshotRevision,firstAvailableSeq:t.firstAvailableSeq,hasOlder:t.hasOlder,messages:t.entries}}),d("historyPages"),d("historyBytesServed",Buffer.byteLength(JSON.stringify(t.entries),"utf8"))}handleClose(s){const e=s.params;p(e),h(e.sessionId,e.subscriptionId);const i=this.subscriptions.get(e.subscriptionId);i?.sessionId===e.sessionId&&(this.flushSubscription(i),this.subscriptions.delete(e.subscriptionId),this.updateSubscriptionMetric()),this.client.sendRes({type:"res",id:s.id,ok:!0,payload:{...u(this.store.daemonInstallationId),sessionId:e.sessionId,subscriptionId:e.subscriptionId,closed:!0}})}closeAll(){for(const s of this.subscriptions.values())this.flushSubscription(s);for(const s of this.replicaStreams.values())this.flushReplicaStream(s);this.subscriptions.clear(),this.replicaStreams.clear(),I(0),this.unsubscribeStore()}closeSession(s){for(const[e,i]of this.subscriptions.entries())i.sessionId===s&&(this.flushSubscription(i),this.subscriptions.delete(e));this.updateSubscriptionMetric()}handleMutation(s){for(const e of this.subscriptions.values())e.sessionId===s.sessionId&&(e.active?this.queueMutation(e,s):e.buffered.push(s));for(const e of this.replicaStreams.values())e.active?this.queueReplicaMutation(e,s):e.buffered.push(s)}queueReplicaMutation(s,e){const i=s.lastQueuedBySession.get(e.sessionId)??0;if(e.revision<=i)return;const t=Buffer.byteLength(JSON.stringify({...e,subscriptionId:s.streamId}),"utf8")+1;s.pendingCount>0&&s.pendingBytes+t>g&&this.flushReplicaStream(s);const n=s.pendingBySession.get(e.sessionId)??[];if(n.push(e),s.pendingBySession.set(e.sessionId,n),s.pendingCount++,s.pendingBytes+=t,s.lastQueuedBySession.set(e.sessionId,e.revision),s.pendingCount>=b){this.flushReplicaStream(s);return}s.flushTimer||(s.flushTimer=setTimeout(()=>this.flushReplicaStream(s),y))}flushReplicaStream(s){if(s.flushTimer&&(clearTimeout(s.flushTimer),s.flushTimer=null),s.pendingCount===0)return;const e=[...s.pendingBySession.entries()].flatMap(([i,t])=>{const n=s.lastSentBySession.get(i)??0,o=t.filter(c=>c.revision>n);if(o.length===0)return[];const r=o[o.length-1].revision;return s.lastSentBySession.set(i,r),[{sessionId:i,fromRevision:n,toRevision:r,mutations:o.map(c=>({...c,subscriptionId:s.streamId}))}]});s.pendingBySession.clear(),s.pendingCount=0,s.pendingBytes=0,e.length!==0&&(this.client.sendEvent({type:"event",event:"session.body.replica.delta",payload:{...u(this.store.daemonInstallationId),appInstallationId:s.appInstallationId,streamId:s.streamId,generation:s.generation,batches:e}}),d("historyDeltas"),d("historyBytesServed",Buffer.byteLength(JSON.stringify(e),"utf8")))}queueMutation(s,e){if(e.revision<=s.lastQueuedRevision)return;const i=Buffer.byteLength(JSON.stringify({...e,subscriptionId:s.subscriptionId}),"utf8")+1;if(s.pending.length>0&&s.pendingBytes+i>g&&this.flushSubscription(s),s.pending.push(e),s.pendingBytes+=i,s.lastQueuedRevision=e.revision,s.pending.length>=b){this.flushSubscription(s);return}s.flushTimer||(s.flushTimer=setTimeout(()=>this.flushSubscription(s),y))}flushSubscription(s){if(s.flushTimer&&(clearTimeout(s.flushTimer),s.flushTimer=null),s.pending.length===0)return;const e=s.pending.filter(t=>t.revision>s.lastSentRevision);if(s.pending=[],s.pendingBytes=0,e.length===0)return;const i=e[e.length-1].revision;this.client.sendEvent({type:"event",event:"session.body.delta",payload:{...u(this.store.daemonInstallationId),sessionId:s.sessionId,subscriptionId:s.subscriptionId,fromRevision:s.lastSentRevision,toRevision:i,mutations:e.map(t=>({...t,subscriptionId:s.subscriptionId}))}}),d("historyDeltas"),d("historyBytesServed",Buffer.byteLength(JSON.stringify(e),"utf8")),s.lastSentRevision=i}requireSubscription(s,e){h(s,e);const i=this.subscriptions.get(e);if(!i||i.sessionId!==s)throw new Error("subscription_not_found");return i}updateSubscriptionMetric(){I(this.subscriptions.size+this.replicaStreams.size)}}function h(a,s){if(typeof a!="string"||!a||typeof s!="string"||!s)throw new Error("invalid history subscription identity")}function v(a,s,e){if(typeof a!="string"||!a||typeof s!="string"||!s||!Number.isSafeInteger(e)||e<1)throw new Error("invalid body replica stream identity")}function R(a,s){const e=a??s;if(!Number.isSafeInteger(e)||e<1||e>l.maxPageMessages)throw new Error("invalid history limit");return e}function T(a){const s=a??l.maxDeltaMutations;if(!Number.isSafeInteger(s)||s<1||s>l.maxDeltaMutations)throw new Error("invalid history sync limit");return s}export{b as PERSONAL_HISTORY_DELTA_BATCH_MAX_MUTATIONS,y as PERSONAL_HISTORY_DELTA_BATCH_WINDOW_MS,O as PersonalHistorySubscriptions};
1
+ // @arch docs/architecture/data-sync.md#8-打开会话快照与实时订阅必须无缝衔接
2
+ // @test src/__tests__/history-subscriptions.test.ts
3
+ import { SESSION_HISTORY_LIMITS, } from '@shennian/wire';
4
+ import { getCanonicalSessionStore, listSessionRecords } from './store.js';
5
+ import { recordPersonalSyncLocalMetric, setPersonalSyncLocalSubscriptions, } from '../personal-sync/metrics.js';
6
+ import { assertPersonalSyncIdentity, getPersonalSyncIdentity } from '../personal-sync/epoch.js';
7
+ export const PERSONAL_HISTORY_DELTA_BATCH_WINDOW_MS = 100;
8
+ export const PERSONAL_HISTORY_DELTA_BATCH_MAX_MUTATIONS = SESSION_HISTORY_LIMITS.maxDeltaMutations;
9
+ const PERSONAL_HISTORY_DELTA_BATCH_MAX_BYTES = SESSION_HISTORY_LIMITS.maxMutationBytes - 4_096;
10
+ const PERSONAL_HISTORY_REPLICA_PAGE_MAX_BYTES = SESSION_HISTORY_LIMITS.maxReplicaPageBytes - 16_384;
11
+ export class PersonalHistorySubscriptions {
12
+ client;
13
+ store;
14
+ hasLocalSession;
15
+ subscriptions = new Map();
16
+ replicaStreams = new Map();
17
+ unsubscribeStore;
18
+ constructor(client, store = getCanonicalSessionStore(), hasLocalSession = (sessionId) => listSessionRecords().some((record) => record.sessionId === sessionId)) {
19
+ this.client = client;
20
+ this.store = store;
21
+ this.hasLocalSession = hasLocalSession;
22
+ this.unsubscribeStore = store.subscribe((mutation) => this.handleMutation(mutation));
23
+ }
24
+ handleOpen(req) {
25
+ const params = req.params;
26
+ assertPersonalSyncIdentity(params);
27
+ validateIdentity(params.sessionId, params.subscriptionId);
28
+ // A missing canonical manifest is valid only for a locally known, newly
29
+ // created empty session. Server metadata from an earlier hard-cut epoch
30
+ // must never be projected as a successful empty transcript.
31
+ if (!this.hasLocalSession(params.sessionId) && !this.store.getManifest(params.sessionId)) {
32
+ throw new Error('history_unavailable');
33
+ }
34
+ if (this.subscriptions.has(params.subscriptionId))
35
+ throw new Error('subscription already exists');
36
+ const sameSessionCount = [...this.subscriptions.values()]
37
+ .filter((item) => item.sessionId === params.sessionId).length;
38
+ if (sameSessionCount >= SESSION_HISTORY_LIMITS.maxSubscriptionsPerSession) {
39
+ this.client.sendRes({
40
+ type: 'res',
41
+ id: req.id,
42
+ ok: false,
43
+ error: 'subscription_limit_exceeded',
44
+ rejection: {
45
+ code: 'subscription_limit_exceeded',
46
+ retryable: true,
47
+ requiredAction: 'retry',
48
+ },
49
+ });
50
+ return;
51
+ }
52
+ const subscription = {
53
+ sessionId: params.sessionId,
54
+ subscriptionId: params.subscriptionId,
55
+ snapshotRevision: 0,
56
+ lastSentRevision: 0,
57
+ active: false,
58
+ buffered: [],
59
+ pending: [],
60
+ pendingBytes: 0,
61
+ flushTimer: null,
62
+ lastQueuedRevision: 0,
63
+ };
64
+ this.subscriptions.set(params.subscriptionId, subscription);
65
+ this.updateSubscriptionMetric();
66
+ try {
67
+ const page = this.store.readPage(params.sessionId, {
68
+ limit: normalizeLimit(params.limit, SESSION_HISTORY_LIMITS.defaultSnapshotMessages),
69
+ ...(params.beforeSequence === undefined ? {} : { beforeSequence: params.beforeSequence }),
70
+ });
71
+ subscription.snapshotRevision = page.bodyRevision;
72
+ subscription.lastSentRevision = page.bodyRevision;
73
+ subscription.lastQueuedRevision = page.bodyRevision;
74
+ this.client.sendRes({
75
+ type: 'res', id: req.id, ok: true,
76
+ payload: {
77
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
78
+ sessionId: params.sessionId,
79
+ subscriptionId: params.subscriptionId,
80
+ storageMode: 'machine_local',
81
+ snapshotRevision: page.bodyRevision,
82
+ firstAvailableSeq: page.firstAvailableSeq,
83
+ lastAvailableSeq: page.lastAvailableSeq,
84
+ hasOlder: page.hasOlder,
85
+ messages: page.entries,
86
+ },
87
+ });
88
+ recordPersonalSyncLocalMetric('historySnapshots');
89
+ recordPersonalSyncLocalMetric('historyBytesServed', Buffer.byteLength(JSON.stringify(page.entries), 'utf8'));
90
+ subscription.active = true;
91
+ for (const mutation of subscription.buffered)
92
+ this.queueMutation(subscription, mutation);
93
+ subscription.buffered = [];
94
+ }
95
+ catch (error) {
96
+ this.subscriptions.delete(params.subscriptionId);
97
+ this.updateSubscriptionMetric();
98
+ throw error;
99
+ }
100
+ }
101
+ handleSync(req) {
102
+ const params = req.params;
103
+ assertPersonalSyncIdentity(params);
104
+ validateIdentity(params.sessionId, params.syncId);
105
+ if (!Number.isSafeInteger(params.afterRevision) || params.afterRevision < 0) {
106
+ throw new Error('invalid history sync revision');
107
+ }
108
+ if (!this.hasLocalSession(params.sessionId) && !this.store.getManifest(params.sessionId)) {
109
+ throw new Error('history_unavailable');
110
+ }
111
+ const targetRevision = this.store.getManifest(params.sessionId)?.bodyRevision ?? 0;
112
+ if (params.afterRevision > targetRevision)
113
+ throw new Error('revision_gap');
114
+ const limit = normalizeSyncLimit(params.limit);
115
+ const available = this.store.readDeltas(params.sessionId, params.afterRevision, limit);
116
+ const mutations = [];
117
+ let bytes = 0;
118
+ for (const mutation of available) {
119
+ const projected = { ...mutation, subscriptionId: params.syncId };
120
+ const mutationBytes = Buffer.byteLength(JSON.stringify(projected), 'utf8') + 1;
121
+ if (mutations.length > 0 && bytes + mutationBytes > PERSONAL_HISTORY_REPLICA_PAGE_MAX_BYTES)
122
+ break;
123
+ if (mutationBytes > PERSONAL_HISTORY_REPLICA_PAGE_MAX_BYTES) {
124
+ this.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'response_too_large' });
125
+ return;
126
+ }
127
+ mutations.push(projected);
128
+ bytes += mutationBytes;
129
+ }
130
+ const toRevision = mutations.at(-1)?.revision ?? params.afterRevision;
131
+ this.client.sendRes({
132
+ type: 'res', id: req.id, ok: true,
133
+ payload: {
134
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
135
+ sessionId: params.sessionId,
136
+ syncId: params.syncId,
137
+ fromRevision: params.afterRevision,
138
+ toRevision,
139
+ targetRevision,
140
+ hasMore: toRevision < targetRevision,
141
+ mutations,
142
+ },
143
+ });
144
+ recordPersonalSyncLocalMetric('historyDeltas');
145
+ recordPersonalSyncLocalMetric('historyBytesServed', bytes);
146
+ }
147
+ handleReplicaSubscribe(req) {
148
+ const params = req.params;
149
+ assertPersonalSyncIdentity(params);
150
+ validateStreamIdentity(params.appInstallationId, params.streamId, params.generation);
151
+ if (!this.replicaStreams.has(params.streamId)
152
+ && this.replicaStreams.size >= SESSION_HISTORY_LIMITS.maxReplicaStreamsPerDaemon) {
153
+ this.client.sendRes({
154
+ type: 'res', id: req.id, ok: false, error: 'subscription_limit_exceeded',
155
+ rejection: {
156
+ code: 'subscription_limit_exceeded', retryable: true, requiredAction: 'retry',
157
+ },
158
+ });
159
+ return;
160
+ }
161
+ const previous = this.replicaStreams.get(params.streamId);
162
+ if (previous && params.generation < previous.generation) {
163
+ throw new Error('invalid body replica generation');
164
+ }
165
+ if (previous)
166
+ this.flushReplicaStream(previous);
167
+ const stream = {
168
+ appInstallationId: params.appInstallationId,
169
+ streamId: params.streamId,
170
+ generation: params.generation,
171
+ active: false,
172
+ buffered: [],
173
+ highWaterBySession: new Map(),
174
+ lastSentBySession: new Map(),
175
+ lastQueuedBySession: new Map(),
176
+ pendingBySession: new Map(),
177
+ pendingCount: 0,
178
+ pendingBytes: 0,
179
+ flushTimer: null,
180
+ };
181
+ // Register before capturing high-water marks. Commits racing this scan are
182
+ // buffered, then filtered against the captured revision after the response.
183
+ this.replicaStreams.set(params.streamId, stream);
184
+ this.updateSubscriptionMetric();
185
+ const highWatermarks = this.store.listSessionIds().map((sessionId) => {
186
+ const bodyRevision = this.store.getManifest(sessionId)?.bodyRevision ?? 0;
187
+ stream.highWaterBySession.set(sessionId, bodyRevision);
188
+ stream.lastSentBySession.set(sessionId, bodyRevision);
189
+ stream.lastQueuedBySession.set(sessionId, bodyRevision);
190
+ return { sessionId, bodyRevision };
191
+ });
192
+ this.client.sendRes({
193
+ type: 'res', id: req.id, ok: true,
194
+ payload: {
195
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
196
+ appInstallationId: params.appInstallationId,
197
+ streamId: params.streamId,
198
+ generation: params.generation,
199
+ highWatermarks,
200
+ },
201
+ });
202
+ stream.active = true;
203
+ for (const mutation of stream.buffered)
204
+ this.queueReplicaMutation(stream, mutation);
205
+ stream.buffered = [];
206
+ }
207
+ handleReplicaUnsubscribe(req) {
208
+ const params = req.params;
209
+ assertPersonalSyncIdentity(params);
210
+ validateStreamIdentity(params.appInstallationId, params.streamId, params.generation);
211
+ const stream = this.replicaStreams.get(params.streamId);
212
+ if (stream && stream.generation === params.generation) {
213
+ this.flushReplicaStream(stream);
214
+ this.replicaStreams.delete(params.streamId);
215
+ this.updateSubscriptionMetric();
216
+ }
217
+ this.client.sendRes({
218
+ type: 'res', id: req.id, ok: true,
219
+ payload: {
220
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
221
+ appInstallationId: params.appInstallationId,
222
+ streamId: params.streamId,
223
+ generation: params.generation,
224
+ closed: true,
225
+ },
226
+ });
227
+ }
228
+ handlePage(req) {
229
+ const params = req.params;
230
+ assertPersonalSyncIdentity(params);
231
+ const subscription = this.requireSubscription(params.sessionId, params.subscriptionId);
232
+ let page;
233
+ try {
234
+ page = this.store.readPageAtRevision(params.sessionId, {
235
+ beforeSequence: params.beforeSequence,
236
+ limit: normalizeLimit(params.limit, SESSION_HISTORY_LIMITS.defaultPageMessages),
237
+ revision: subscription.snapshotRevision,
238
+ });
239
+ }
240
+ catch {
241
+ throw new Error('snapshot_compacted');
242
+ }
243
+ this.client.sendRes({
244
+ type: 'res', id: req.id, ok: true,
245
+ payload: {
246
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
247
+ sessionId: params.sessionId,
248
+ subscriptionId: params.subscriptionId,
249
+ snapshotRevision: subscription.snapshotRevision,
250
+ firstAvailableSeq: page.firstAvailableSeq,
251
+ hasOlder: page.hasOlder,
252
+ messages: page.entries,
253
+ },
254
+ });
255
+ recordPersonalSyncLocalMetric('historyPages');
256
+ recordPersonalSyncLocalMetric('historyBytesServed', Buffer.byteLength(JSON.stringify(page.entries), 'utf8'));
257
+ }
258
+ handleClose(req) {
259
+ const params = req.params;
260
+ assertPersonalSyncIdentity(params);
261
+ validateIdentity(params.sessionId, params.subscriptionId);
262
+ const subscription = this.subscriptions.get(params.subscriptionId);
263
+ if (subscription?.sessionId === params.sessionId) {
264
+ this.flushSubscription(subscription);
265
+ this.subscriptions.delete(params.subscriptionId);
266
+ this.updateSubscriptionMetric();
267
+ }
268
+ this.client.sendRes({
269
+ type: 'res', id: req.id, ok: true,
270
+ payload: {
271
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
272
+ sessionId: params.sessionId, subscriptionId: params.subscriptionId, closed: true,
273
+ },
274
+ });
275
+ }
276
+ closeAll() {
277
+ for (const subscription of this.subscriptions.values())
278
+ this.flushSubscription(subscription);
279
+ for (const stream of this.replicaStreams.values())
280
+ this.flushReplicaStream(stream);
281
+ this.subscriptions.clear();
282
+ this.replicaStreams.clear();
283
+ setPersonalSyncLocalSubscriptions(0);
284
+ this.unsubscribeStore();
285
+ }
286
+ closeSession(sessionId) {
287
+ for (const [subscriptionId, subscription] of this.subscriptions.entries()) {
288
+ if (subscription.sessionId !== sessionId)
289
+ continue;
290
+ this.flushSubscription(subscription);
291
+ this.subscriptions.delete(subscriptionId);
292
+ }
293
+ this.updateSubscriptionMetric();
294
+ }
295
+ handleMutation(mutation) {
296
+ for (const subscription of this.subscriptions.values()) {
297
+ if (subscription.sessionId !== mutation.sessionId)
298
+ continue;
299
+ if (!subscription.active)
300
+ subscription.buffered.push(mutation);
301
+ else
302
+ this.queueMutation(subscription, mutation);
303
+ }
304
+ for (const stream of this.replicaStreams.values()) {
305
+ if (!stream.active)
306
+ stream.buffered.push(mutation);
307
+ else
308
+ this.queueReplicaMutation(stream, mutation);
309
+ }
310
+ }
311
+ queueReplicaMutation(stream, mutation) {
312
+ const lastQueued = stream.lastQueuedBySession.get(mutation.sessionId) ?? 0;
313
+ if (mutation.revision <= lastQueued)
314
+ return;
315
+ const mutationBytes = Buffer.byteLength(JSON.stringify({
316
+ ...mutation,
317
+ subscriptionId: stream.streamId,
318
+ }), 'utf8') + 1;
319
+ if (stream.pendingCount > 0
320
+ && stream.pendingBytes + mutationBytes > PERSONAL_HISTORY_DELTA_BATCH_MAX_BYTES) {
321
+ this.flushReplicaStream(stream);
322
+ }
323
+ const pending = stream.pendingBySession.get(mutation.sessionId) ?? [];
324
+ pending.push(mutation);
325
+ stream.pendingBySession.set(mutation.sessionId, pending);
326
+ stream.pendingCount++;
327
+ stream.pendingBytes += mutationBytes;
328
+ stream.lastQueuedBySession.set(mutation.sessionId, mutation.revision);
329
+ if (stream.pendingCount >= PERSONAL_HISTORY_DELTA_BATCH_MAX_MUTATIONS) {
330
+ this.flushReplicaStream(stream);
331
+ return;
332
+ }
333
+ if (stream.flushTimer)
334
+ return;
335
+ stream.flushTimer = setTimeout(() => this.flushReplicaStream(stream), PERSONAL_HISTORY_DELTA_BATCH_WINDOW_MS);
336
+ }
337
+ flushReplicaStream(stream) {
338
+ if (stream.flushTimer) {
339
+ clearTimeout(stream.flushTimer);
340
+ stream.flushTimer = null;
341
+ }
342
+ if (stream.pendingCount === 0)
343
+ return;
344
+ const batches = [...stream.pendingBySession.entries()].flatMap(([sessionId, pending]) => {
345
+ const lastSent = stream.lastSentBySession.get(sessionId) ?? 0;
346
+ const mutations = pending.filter((mutation) => mutation.revision > lastSent);
347
+ if (mutations.length === 0)
348
+ return [];
349
+ const toRevision = mutations[mutations.length - 1].revision;
350
+ stream.lastSentBySession.set(sessionId, toRevision);
351
+ return [{
352
+ sessionId,
353
+ fromRevision: lastSent,
354
+ toRevision,
355
+ mutations: mutations.map((mutation) => ({
356
+ ...mutation,
357
+ subscriptionId: stream.streamId,
358
+ })),
359
+ }];
360
+ });
361
+ stream.pendingBySession.clear();
362
+ stream.pendingCount = 0;
363
+ stream.pendingBytes = 0;
364
+ if (batches.length === 0)
365
+ return;
366
+ this.client.sendEvent({
367
+ type: 'event',
368
+ event: 'session.body.replica.delta',
369
+ payload: {
370
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
371
+ appInstallationId: stream.appInstallationId,
372
+ streamId: stream.streamId,
373
+ generation: stream.generation,
374
+ batches,
375
+ },
376
+ });
377
+ recordPersonalSyncLocalMetric('historyDeltas');
378
+ recordPersonalSyncLocalMetric('historyBytesServed', Buffer.byteLength(JSON.stringify(batches), 'utf8'));
379
+ }
380
+ queueMutation(subscription, mutation) {
381
+ if (mutation.revision <= subscription.lastQueuedRevision)
382
+ return;
383
+ const mutationBytes = Buffer.byteLength(JSON.stringify({
384
+ ...mutation,
385
+ subscriptionId: subscription.subscriptionId,
386
+ }), 'utf8') + 1;
387
+ if (subscription.pending.length > 0 &&
388
+ subscription.pendingBytes + mutationBytes > PERSONAL_HISTORY_DELTA_BATCH_MAX_BYTES)
389
+ this.flushSubscription(subscription);
390
+ subscription.pending.push(mutation);
391
+ subscription.pendingBytes += mutationBytes;
392
+ subscription.lastQueuedRevision = mutation.revision;
393
+ if (subscription.pending.length >= PERSONAL_HISTORY_DELTA_BATCH_MAX_MUTATIONS) {
394
+ this.flushSubscription(subscription);
395
+ return;
396
+ }
397
+ if (subscription.flushTimer)
398
+ return;
399
+ subscription.flushTimer = setTimeout(() => this.flushSubscription(subscription), PERSONAL_HISTORY_DELTA_BATCH_WINDOW_MS);
400
+ }
401
+ flushSubscription(subscription) {
402
+ if (subscription.flushTimer) {
403
+ clearTimeout(subscription.flushTimer);
404
+ subscription.flushTimer = null;
405
+ }
406
+ if (subscription.pending.length === 0)
407
+ return;
408
+ const mutations = subscription.pending
409
+ .filter((mutation) => mutation.revision > subscription.lastSentRevision);
410
+ subscription.pending = [];
411
+ subscription.pendingBytes = 0;
412
+ if (mutations.length === 0)
413
+ return;
414
+ const toRevision = mutations[mutations.length - 1].revision;
415
+ this.client.sendEvent({
416
+ type: 'event',
417
+ event: 'session.body.delta',
418
+ payload: {
419
+ ...getPersonalSyncIdentity(this.store.daemonInstallationId),
420
+ sessionId: subscription.sessionId,
421
+ subscriptionId: subscription.subscriptionId,
422
+ fromRevision: subscription.lastSentRevision,
423
+ toRevision,
424
+ mutations: mutations.map((mutation) => ({
425
+ ...mutation,
426
+ subscriptionId: subscription.subscriptionId,
427
+ })),
428
+ },
429
+ });
430
+ recordPersonalSyncLocalMetric('historyDeltas');
431
+ recordPersonalSyncLocalMetric('historyBytesServed', Buffer.byteLength(JSON.stringify(mutations), 'utf8'));
432
+ subscription.lastSentRevision = toRevision;
433
+ }
434
+ requireSubscription(sessionId, subscriptionId) {
435
+ validateIdentity(sessionId, subscriptionId);
436
+ const subscription = this.subscriptions.get(subscriptionId);
437
+ if (!subscription || subscription.sessionId !== sessionId)
438
+ throw new Error('subscription_not_found');
439
+ return subscription;
440
+ }
441
+ updateSubscriptionMetric() {
442
+ setPersonalSyncLocalSubscriptions(this.subscriptions.size + this.replicaStreams.size);
443
+ }
444
+ }
445
+ function validateIdentity(sessionId, subscriptionId) {
446
+ if (typeof sessionId !== 'string' || !sessionId || typeof subscriptionId !== 'string' || !subscriptionId) {
447
+ throw new Error('invalid history subscription identity');
448
+ }
449
+ }
450
+ function validateStreamIdentity(appInstallationId, streamId, generation) {
451
+ if (typeof appInstallationId !== 'string' || !appInstallationId
452
+ || typeof streamId !== 'string' || !streamId
453
+ || !Number.isSafeInteger(generation) || generation < 1) {
454
+ throw new Error('invalid body replica stream identity');
455
+ }
456
+ }
457
+ function normalizeLimit(value, fallback) {
458
+ const limit = value ?? fallback;
459
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SESSION_HISTORY_LIMITS.maxPageMessages) {
460
+ throw new Error('invalid history limit');
461
+ }
462
+ return limit;
463
+ }
464
+ function normalizeSyncLimit(value) {
465
+ const limit = value ?? SESSION_HISTORY_LIMITS.maxDeltaMutations;
466
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SESSION_HISTORY_LIMITS.maxDeltaMutations) {
467
+ throw new Error('invalid history sync limit');
468
+ }
469
+ return limit;
470
+ }