crewly 1.20.149 → 1.20.151

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 (149) hide show
  1. package/dist/backend/backend/src/constants.d.ts +100 -0
  2. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  3. package/dist/backend/backend/src/constants.js +101 -0
  4. package/dist/backend/backend/src/constants.js.map +1 -1
  5. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts +1 -0
  6. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
  7. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +22 -0
  8. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.d.ts +1 -0
  10. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.d.ts.map +1 -1
  11. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.js +2 -0
  12. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.js.map +1 -1
  13. package/dist/backend/backend/src/controllers/messaging/messenger.routes.d.ts.map +1 -1
  14. package/dist/backend/backend/src/controllers/messaging/messenger.routes.js +42 -1
  15. package/dist/backend/backend/src/controllers/messaging/messenger.routes.js.map +1 -1
  16. package/dist/backend/backend/src/index.d.ts +8 -0
  17. package/dist/backend/backend/src/index.d.ts.map +1 -1
  18. package/dist/backend/backend/src/index.js +109 -1
  19. package/dist/backend/backend/src/index.js.map +1 -1
  20. package/dist/backend/backend/src/services/chat-v2/chat-v2.relay-adapter.service.d.ts.map +1 -1
  21. package/dist/backend/backend/src/services/chat-v2/chat-v2.relay-adapter.service.js +3 -0
  22. package/dist/backend/backend/src/services/chat-v2/chat-v2.relay-adapter.service.js.map +1 -1
  23. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +94 -2
  24. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +183 -2
  26. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  27. package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.d.ts +70 -0
  28. package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.js +78 -0
  30. package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.js.map +1 -1
  31. package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.d.ts.map +1 -1
  32. package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.js +9 -0
  33. package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.js.map +1 -1
  34. package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts +136 -0
  35. package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts.map +1 -0
  36. package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js +207 -0
  37. package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js.map +1 -0
  38. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +62 -1
  39. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  40. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +111 -4
  41. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  42. package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.d.ts +206 -0
  43. package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.d.ts.map +1 -0
  44. package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.js +442 -0
  45. package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.js.map +1 -0
  46. package/dist/backend/backend/src/services/chat-v2/types.d.ts +29 -0
  47. package/dist/backend/backend/src/services/chat-v2/types.d.ts.map +1 -1
  48. package/dist/backend/backend/src/services/chat-v2/types.js.map +1 -1
  49. package/dist/backend/backend/src/services/cloud/agent-roster.utils.d.ts +31 -0
  50. package/dist/backend/backend/src/services/cloud/agent-roster.utils.d.ts.map +1 -0
  51. package/dist/backend/backend/src/services/cloud/agent-roster.utils.js +53 -0
  52. package/dist/backend/backend/src/services/cloud/agent-roster.utils.js.map +1 -0
  53. package/dist/backend/backend/src/services/cloud/cloud-sync.types.d.ts +1 -1
  54. package/dist/backend/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -1
  55. package/dist/backend/backend/src/services/cloud/cloud-sync.types.js +1 -0
  56. package/dist/backend/backend/src/services/cloud/cloud-sync.types.js.map +1 -1
  57. package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.d.ts +125 -0
  58. package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.d.ts.map +1 -0
  59. package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js +272 -0
  60. package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js.map +1 -0
  61. package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.d.ts +229 -0
  62. package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.d.ts.map +1 -0
  63. package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.js +655 -0
  64. package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.js.map +1 -0
  65. package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.d.ts +215 -0
  66. package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.d.ts.map +1 -0
  67. package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.js +193 -0
  68. package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.js.map +1 -0
  69. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +4 -0
  71. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  72. package/dist/backend/backend/src/services/messaging/google-chat-initializer.d.ts.map +1 -1
  73. package/dist/backend/backend/src/services/messaging/google-chat-initializer.js +11 -1
  74. package/dist/backend/backend/src/services/messaging/google-chat-initializer.js.map +1 -1
  75. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts +1 -1
  76. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  77. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +13 -0
  78. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  79. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  80. package/dist/backend/backend/src/services/slack/slack-initializer.js +15 -0
  81. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  82. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.d.ts +5 -0
  83. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.d.ts.map +1 -1
  84. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.js +5 -0
  85. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.js.map +1 -1
  86. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +14 -1
  87. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  88. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +34 -2
  89. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  90. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  91. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +1 -0
  92. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  93. package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.d.ts +10 -0
  94. package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.d.ts.map +1 -1
  95. package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.js +27 -2
  96. package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.js.map +1 -1
  97. package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.d.ts +10 -0
  98. package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.d.ts.map +1 -1
  99. package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.js +27 -4
  100. package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.js.map +1 -1
  101. package/dist/backend/backend/src/types/slack.types.d.ts +24 -0
  102. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  103. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  104. package/dist/backend/build-info.json +2 -2
  105. package/dist/cli/backend/src/constants.d.ts +100 -0
  106. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  107. package/dist/cli/backend/src/constants.js +101 -0
  108. package/dist/cli/backend/src/constants.js.map +1 -1
  109. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +94 -2
  110. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  111. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +183 -2
  112. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  113. package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.d.ts.map +1 -1
  114. package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js +9 -0
  115. package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js.map +1 -1
  116. package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts +136 -0
  117. package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts.map +1 -0
  118. package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js +207 -0
  119. package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js.map +1 -0
  120. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +62 -1
  121. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  122. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +111 -4
  123. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  124. package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.d.ts +206 -0
  125. package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.d.ts.map +1 -0
  126. package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.js +442 -0
  127. package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.js.map +1 -0
  128. package/dist/cli/backend/src/services/chat-v2/types.d.ts +29 -0
  129. package/dist/cli/backend/src/services/chat-v2/types.d.ts.map +1 -1
  130. package/dist/cli/backend/src/services/chat-v2/types.js.map +1 -1
  131. package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts +1 -1
  132. package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -1
  133. package/dist/cli/backend/src/services/cloud/cloud-sync.types.js +1 -0
  134. package/dist/cli/backend/src/services/cloud/cloud-sync.types.js.map +1 -1
  135. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts +1 -1
  136. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  137. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +13 -0
  138. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  139. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +14 -1
  140. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  141. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +34 -2
  142. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  143. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  144. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +1 -0
  145. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  146. package/dist/cli/backend/src/types/slack.types.d.ts +24 -0
  147. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  148. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  149. package/package.json +1 -1
@@ -0,0 +1,655 @@
1
+ /**
2
+ * ConversationCloudSyncService — uploads this machine's conversation log to
3
+ * Crewly Cloud (specs/unified-conversations-cloud-store.md §B).
4
+ *
5
+ * - **Live:** drains `cloud_outbox` (filled by triggers on `chat_messages`)
6
+ * in `seq` order, one request in flight, batches of ≤ 50 messages /
7
+ * 256 KB, gzip. Wakes on every new chat message (after a 2 s gather) and
8
+ * on a 10 s tick. Rows leave the outbox only once Cloud acknowledged them.
9
+ * - **Backfill:** after first sign-in (or an account switch, or a plan
10
+ * upgrade that widens the window) it re-sends the history inside the
11
+ * plan's retention window, oldest first, at most one request a second,
12
+ * after the live queue.
13
+ * - **Disconnected / signed out:** the outbox stays on disk; it is only
14
+ * trimmed (older than 90 days, more than 200k rows — a history gap).
15
+ * - **Failures:** exponential backoff 1 s → 5 min. A 404 (Cloud has no
16
+ * conversation store yet), `403 sync_disabled`, `503` key missing or a
17
+ * `400` batch Cloud refuses pause sync for an hour, logged once.
18
+ * - **O1:** on by default for a signed-in machine; the owner gets one Slack
19
+ * DM the first time history reaches Cloud. `CREWLY_CONVERSATION_SYNC=0`
20
+ * (or `CREWLY_CLOUD_CONVERSATIONS=off`) turns uploading off; the outbox
21
+ * still fills.
22
+ * - **O4:** text only — attachments go as name / size / mime.
23
+ * - **Roster (Cloud Talk, Phase 3):** every 5 minutes, and whenever the
24
+ * advertised capabilities change, an empty batch carries this machine's
25
+ * agent roster and capabilities (`talk_message`), so Cloud lists agents
26
+ * that have no messages yet and knows Talk can be sent here — also on
27
+ * machines that do not use Slack (and so never send the Slack heartbeat).
28
+ *
29
+ * The wire contract lives in `conversation-ingest.contract.ts`.
30
+ *
31
+ * @module services/cloud/conversation-cloud-sync.service
32
+ */
33
+ import { gzipSync } from 'zlib';
34
+ import { randomUUID } from 'crypto';
35
+ import { CLOUD_TALK_CONSTANTS, CONVERSATION_SYNC_CONSTANTS, ORCHESTRATOR_SESSION_NAME } from '../../constants.js';
36
+ import { LoggerService } from '../core/logger.service.js';
37
+ import { CONVERSATION_DIRECTIONS, CONVERSATION_INGEST_PATH, CONVERSATION_SOURCES, INGEST_ERROR_CODES, INGEST_LIMITS, INSTANCE_ID_PATTERN, SENDER_KINDS, isOneOf, parseIngestErrorCode, parseIngestResponse, } from './conversation-ingest.contract.js';
38
+ const C = CONVERSATION_SYNC_CONSTANTS;
39
+ const K = C.STATE_KEYS;
40
+ const DAY_MS = 24 * 60 * 60 * 1000;
41
+ /**
42
+ * Whether the kill switch is set.
43
+ *
44
+ * @param env - Environment
45
+ * @returns True when uploading is switched off
46
+ */
47
+ export function isConversationSyncDisabled(env) {
48
+ const off = (v) => typeof v === 'string' && ['0', 'off', 'false', 'no'].includes(v.trim().toLowerCase());
49
+ return off(env[C.ENV_SWITCH]) || off(env[C.ENV_SWITCH_ALT]);
50
+ }
51
+ /**
52
+ * The account a Cloud access token belongs to — the JWT `sub`, read without
53
+ * verifying. An opaque token yields a constant, so token refreshes are never
54
+ * mistaken for an account switch.
55
+ *
56
+ * @param token - Access token
57
+ * @returns The account id
58
+ */
59
+ export function accountIdOfToken(token) {
60
+ const parts = token.split('.');
61
+ if (parts.length === 3) {
62
+ try {
63
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
64
+ if (typeof payload.sub === 'string' && payload.sub.length > 0)
65
+ return payload.sub;
66
+ }
67
+ catch {
68
+ // not a JWT
69
+ }
70
+ }
71
+ return 'opaque-token';
72
+ }
73
+ /**
74
+ * Parse a JSON column without throwing.
75
+ *
76
+ * @param raw - Column value
77
+ * @returns The value, or undefined
78
+ */
79
+ function parseJson(raw) {
80
+ if (!raw)
81
+ return undefined;
82
+ try {
83
+ return JSON.parse(raw);
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ /**
90
+ * Map one stored message to the wire shape.
91
+ *
92
+ * @param row - Message row with its channel and attachment metadata
93
+ * @param localSeq - Outbox seq (live) or 0 (backfill)
94
+ * @returns The upsert, or null when the row must not leave the machine
95
+ */
96
+ export function toIngestUpsert(row, localSeq) {
97
+ if (row.cloudSync === 0)
98
+ return null;
99
+ const mentionsRaw = parseJson(row.mentions);
100
+ const mentions = Array.isArray(mentionsRaw) ? mentionsRaw.filter((m) => typeof m === 'string' && m.length > 0) : [];
101
+ const metadata = parseJson(row.metadata);
102
+ const md = metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? metadata : {};
103
+ const extRaw = parseJson(row.extRef);
104
+ const ext = extRaw && typeof extRaw === 'object' && !Array.isArray(extRaw) && Object.keys(extRaw).length > 0 ? extRaw : undefined;
105
+ const source = isOneOf(CONVERSATION_SOURCES, row.source) ? row.source : 'crewly-chat';
106
+ const direction = isOneOf(CONVERSATION_DIRECTIONS, row.direction)
107
+ ? row.direction
108
+ : row.senderType === 'agent'
109
+ ? 'out'
110
+ : row.senderType === 'system'
111
+ ? 'internal'
112
+ : 'in';
113
+ const senderKind = isOneOf(SENDER_KINDS, row.senderKind)
114
+ ? row.senderKind
115
+ : row.senderType === 'user'
116
+ ? 'owner'
117
+ : row.senderType;
118
+ const agentSession = row.agentSession || mentions[0] || row.leadMember || ORCHESTRATOR_SESSION_NAME;
119
+ // A Slack person is identified by their Slack id; the display name rides along.
120
+ const slackUserId = typeof ext?.slackUserId === 'string' ? ext.slackUserId : undefined;
121
+ const sender = row.senderType === 'user' && source === 'slack' && slackUserId && slackUserId !== row.senderId
122
+ ? { id: slackUserId, name: row.senderId }
123
+ : { id: row.senderId || row.senderType };
124
+ const clientMessageId = source === 'cloud-talk' && typeof md.clientMessageId === 'string' ? md.clientMessageId : undefined;
125
+ return {
126
+ localId: row.id,
127
+ op: 'upsert',
128
+ channel: { localId: row.channelId, kind: row.channelType, name: row.channelName },
129
+ agentSession,
130
+ ...(mentions.length > 0 ? { mentions } : {}),
131
+ source,
132
+ direction,
133
+ senderKind,
134
+ sender,
135
+ ...(ext ? { ext } : {}),
136
+ ...(row.threadId ? { threadLocalId: row.threadId } : {}),
137
+ text: row.content,
138
+ contentType: row.contentType,
139
+ attachments: row.attachments.map((a) => ({
140
+ kind: a.kind,
141
+ mime: a.mimeType,
142
+ size: a.sizeBytes,
143
+ ...(a.originalName ? { name: a.originalName } : {}),
144
+ })),
145
+ ...(clientMessageId ? { clientMessageId } : {}),
146
+ createdAt: row.createdAt,
147
+ localSeq,
148
+ };
149
+ }
150
+ /**
151
+ * Bytes one message adds to a request body (JSON, before gzip).
152
+ *
153
+ * @param message - Wire message
154
+ * @returns Approximate byte size
155
+ */
156
+ function sizeOf(message) {
157
+ return Buffer.byteLength(JSON.stringify(message), 'utf8') + 1;
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Service
161
+ // ---------------------------------------------------------------------------
162
+ /** Uploads the machine's conversation log to Crewly Cloud. */
163
+ export class ConversationCloudSyncService {
164
+ deps;
165
+ logger;
166
+ fetchImpl;
167
+ now;
168
+ setTimer;
169
+ clearTimer;
170
+ started = false;
171
+ running = false;
172
+ rerun = false;
173
+ tickTimer = null;
174
+ wakeTimer = null;
175
+ unsubscribe = null;
176
+ /** Consecutive failures; drives the exponential backoff. */
177
+ failures = 0;
178
+ /** No request before this time (backoff or an hour-long pause). */
179
+ pausedUntil = 0;
180
+ /** Reasons already logged, so a long pause logs once. */
181
+ loggedPauses = new Set();
182
+ /** Current live batch cap; halved on 413. */
183
+ batchLimit = C.BATCH_MAX_MESSAGES;
184
+ lastBackfillAt = 0;
185
+ lastNoticeAttemptAt = 0;
186
+ gapLogged = false;
187
+ /** When the roster last reached Cloud, and the capabilities it carried. */
188
+ lastRosterAt = 0;
189
+ lastCapabilitiesKey = null;
190
+ constructor(deps) {
191
+ this.deps = deps;
192
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('ConversationCloudSync');
193
+ this.fetchImpl = deps.fetchImpl ?? ((input, init) => fetch(input, init));
194
+ this.now = deps.now ?? Date.now;
195
+ this.setTimer = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
196
+ this.clearTimer = deps.clearTimeout ?? ((h) => clearTimeout(h));
197
+ }
198
+ /** Start the tick and the new-message wake-up. Idempotent; never throws. */
199
+ start() {
200
+ if (this.started)
201
+ return;
202
+ this.started = true;
203
+ if (isConversationSyncDisabled(this.deps.env ?? process.env)) {
204
+ this.logger.info('Conversation sync to Crewly Cloud is off (CREWLY_CONVERSATION_SYNC=0); the local log still fills');
205
+ }
206
+ try {
207
+ this.unsubscribe = this.deps.onNewMessage?.(() => this.wake()) ?? null;
208
+ }
209
+ catch {
210
+ this.unsubscribe = null;
211
+ }
212
+ this.scheduleTick();
213
+ this.wake(0);
214
+ }
215
+ /** Stop timers and the wake-up subscription. */
216
+ stop() {
217
+ this.started = false;
218
+ if (this.tickTimer)
219
+ this.clearTimer(this.tickTimer);
220
+ if (this.wakeTimer)
221
+ this.clearTimer(this.wakeTimer);
222
+ this.tickTimer = null;
223
+ this.wakeTimer = null;
224
+ try {
225
+ this.unsubscribe?.();
226
+ }
227
+ catch {
228
+ // ignore
229
+ }
230
+ this.unsubscribe = null;
231
+ }
232
+ /**
233
+ * Run one sync pass now (tests, and the timers). Never throws; a pass
234
+ * already in flight makes this one run again right after it.
235
+ */
236
+ async syncNow() {
237
+ if (this.running) {
238
+ this.rerun = true;
239
+ return;
240
+ }
241
+ this.running = true;
242
+ try {
243
+ do {
244
+ this.rerun = false;
245
+ await this.pass();
246
+ } while (this.rerun);
247
+ }
248
+ catch (error) {
249
+ this.logger.warn('Conversation sync pass failed', { error: error instanceof Error ? error.message : String(error) });
250
+ }
251
+ finally {
252
+ this.running = false;
253
+ }
254
+ }
255
+ /**
256
+ * Schedule a pass after a new message: immediately once a full batch is
257
+ * waiting, otherwise after the gather delay.
258
+ *
259
+ * @param delayMs - Override the delay
260
+ */
261
+ wake(delayMs) {
262
+ if (!this.started)
263
+ return;
264
+ let delay = delayMs;
265
+ if (delay === undefined) {
266
+ let waiting = 0;
267
+ try {
268
+ waiting = this.deps.outbox.count();
269
+ }
270
+ catch {
271
+ waiting = 0;
272
+ }
273
+ delay = waiting >= this.batchLimit ? 0 : C.BATCH_MAX_WAIT_MS;
274
+ }
275
+ if (this.wakeTimer)
276
+ return;
277
+ this.wakeTimer = this.setTimer(() => {
278
+ this.wakeTimer = null;
279
+ void this.syncNow();
280
+ }, delay);
281
+ }
282
+ /** Keep the periodic tick going. */
283
+ scheduleTick() {
284
+ if (!this.started)
285
+ return;
286
+ this.tickTimer = this.setTimer(() => {
287
+ this.tickTimer = null;
288
+ void this.syncNow().finally(() => this.scheduleTick());
289
+ }, C.TICK_INTERVAL_MS);
290
+ }
291
+ /** One pass: trim, check the session, drain live, then one backfill step. */
292
+ async pass() {
293
+ this.trimOutbox();
294
+ if (isConversationSyncDisabled(this.deps.env ?? process.env))
295
+ return;
296
+ const token = this.deps.cloud.getToken();
297
+ const base = this.deps.cloud.getCloudUrl();
298
+ if (!token || !base)
299
+ return;
300
+ if (this.now() < this.pausedUntil)
301
+ return;
302
+ this.checkAccount(token);
303
+ const { instanceId, deviceName } = await this.deps.identity();
304
+ if (!INSTANCE_ID_PATTERN.test(instanceId)) {
305
+ this.pause('invalid_batch', `instance id "${instanceId}" is not accepted by Cloud`);
306
+ return;
307
+ }
308
+ const version = (await this.deps.crewlyVersion?.().catch(() => undefined)) ?? undefined;
309
+ const envelope = { instanceId, deviceName, version };
310
+ // The plan window comes from an empty ingest (spec §B.4); the same empty
311
+ // batch carries the roster when it is due.
312
+ const roster = await this.rosterIfDue();
313
+ if (!this.deps.outbox.getState(K.RETENTION_DAYS) || roster) {
314
+ const probe = await this.send(base, token, envelope, 'live', [], roster ?? undefined);
315
+ if (!this.settle(probe))
316
+ return;
317
+ if (roster) {
318
+ this.lastRosterAt = this.now();
319
+ this.lastCapabilitiesKey = JSON.stringify(roster.capabilities ?? []);
320
+ }
321
+ }
322
+ if (!(await this.drainLive(base, token, envelope)))
323
+ return;
324
+ await this.backfillStep(base, token, envelope);
325
+ }
326
+ /**
327
+ * The roster and capabilities when they are due: every
328
+ * {@link CLOUD_TALK_CONSTANTS.ROSTER_INTERVAL_MS}, or at once when the
329
+ * capabilities changed (the Talk handler starting after the uploader).
330
+ *
331
+ * @returns The fields to send, or null when nothing is due
332
+ */
333
+ async rosterIfDue() {
334
+ if (!this.deps.roster && !this.deps.capabilities)
335
+ return null;
336
+ let capabilities;
337
+ try {
338
+ capabilities = this.deps.capabilities?.();
339
+ }
340
+ catch {
341
+ capabilities = undefined;
342
+ }
343
+ const capsChanged = capabilities !== undefined && JSON.stringify(capabilities) !== this.lastCapabilitiesKey;
344
+ if (!capsChanged && this.now() - this.lastRosterAt < CLOUD_TALK_CONSTANTS.ROSTER_INTERVAL_MS)
345
+ return null;
346
+ let roster;
347
+ try {
348
+ roster = await this.deps.roster?.();
349
+ }
350
+ catch (error) {
351
+ this.logger.debug('Agent roster unavailable for Crewly Cloud', { error: error instanceof Error ? error.message : String(error) });
352
+ roster = undefined;
353
+ }
354
+ if (!roster && capabilities === undefined)
355
+ return null;
356
+ return { ...(roster ? { roster } : {}), ...(capabilities !== undefined ? { capabilities } : {}) };
357
+ }
358
+ /**
359
+ * Upload every waiting outbox row, oldest first.
360
+ *
361
+ * @returns False when a request failed (the pass stops)
362
+ */
363
+ async drainLive(base, token, envelope) {
364
+ for (;;) {
365
+ const entries = this.deps.outbox.peek(this.batchLimit);
366
+ if (entries.length === 0)
367
+ return true;
368
+ const rows = this.deps.outbox.getMessages(entries.filter((e) => e.op === 'upsert').map((e) => e.messageId));
369
+ const messages = [];
370
+ let bytes = 0;
371
+ let lastSeq = entries[0].seq - 1;
372
+ for (const entry of entries) {
373
+ let message;
374
+ if (entry.op === 'delete') {
375
+ message = { localId: entry.messageId, localSeq: entry.seq, op: 'delete' };
376
+ }
377
+ else {
378
+ const row = rows.get(entry.messageId);
379
+ // Deleted since, or not for Cloud: settled without sending.
380
+ message = row ? toIngestUpsert(row, entry.seq) : null;
381
+ }
382
+ if (message) {
383
+ const size = sizeOf(message);
384
+ if (messages.length > 0 && bytes + size > C.BATCH_MAX_BYTES)
385
+ break;
386
+ messages.push(message);
387
+ bytes += size;
388
+ }
389
+ lastSeq = entry.seq;
390
+ }
391
+ if (messages.length === 0) {
392
+ this.deps.outbox.ackThrough(lastSeq);
393
+ continue;
394
+ }
395
+ const outcome = await this.send(base, token, envelope, 'live', messages);
396
+ if (!outcome.ok && outcome.status === 413) {
397
+ if (messages.length > 1) {
398
+ this.batchLimit = Math.max(1, Math.floor(messages.length / 2));
399
+ continue;
400
+ }
401
+ // One message Cloud will never take: drop it rather than wedge the outbox.
402
+ this.logger.warn('Cloud refused a message as too large; skipping it', { localId: messages[0].localId });
403
+ this.deps.outbox.ackThrough(lastSeq);
404
+ continue;
405
+ }
406
+ if (!this.settle(outcome))
407
+ return false;
408
+ const acked = outcome.ok ? outcome.response.ackedThroughLocalSeq : null;
409
+ this.deps.outbox.ackThrough(acked === null ? lastSeq : Math.min(acked, lastSeq));
410
+ if (acked !== null && acked < lastSeq)
411
+ return true; // Cloud settled less; resume next pass.
412
+ }
413
+ }
414
+ /**
415
+ * Send one page of history when a backfill is owed (first sign-in, account
416
+ * switch, or a wider window after an upgrade). At most one request per
417
+ * second; live rows always go first.
418
+ */
419
+ async backfillStep(base, token, envelope) {
420
+ const retention = Number(this.deps.outbox.getState(K.RETENTION_DAYS)) || C.DEFAULT_RETENTION_DAYS;
421
+ const doneFor = Number(this.deps.outbox.getState(K.BACKFILL_RETENTION_DAYS)) || 0;
422
+ if (this.deps.outbox.getState(K.BACKFILL_DONE_AT) && doneFor >= retention)
423
+ return;
424
+ if (this.deps.outbox.getState(K.BACKFILL_DONE_AT) && doneFor < retention) {
425
+ // Plan upgrade: a wider window — send it again from the start.
426
+ this.deps.outbox.setState(K.BACKFILL_DONE_AT, null);
427
+ this.deps.outbox.setState(K.BACKFILL_CURSOR, null);
428
+ }
429
+ const sinceLast = this.now() - this.lastBackfillAt;
430
+ if (sinceLast < C.BACKFILL_MIN_INTERVAL_MS) {
431
+ this.wake(C.BACKFILL_MIN_INTERVAL_MS - sinceLast);
432
+ return;
433
+ }
434
+ if (!this.deps.outbox.getState(K.BACKFILL_CURSOR)) {
435
+ try {
436
+ this.deps.reclassifyOwnerRows?.();
437
+ }
438
+ catch {
439
+ // best effort
440
+ }
441
+ }
442
+ const cursorRaw = parseJson(this.deps.outbox.getState(K.BACKFILL_CURSOR));
443
+ const cursor = cursorRaw && typeof cursorRaw.createdAt === 'number' && typeof cursorRaw.rowid === 'number' ? cursorRaw : null;
444
+ const since = this.now() - retention * DAY_MS;
445
+ const page = this.deps.outbox.backfillPage(since, cursor, Math.min(C.BACKFILL_PAGE_SIZE, INGEST_LIMITS.MAX_MESSAGES));
446
+ if (page.length === 0) {
447
+ this.deps.outbox.setState(K.BACKFILL_DONE_AT, String(this.now()));
448
+ this.deps.outbox.setState(K.BACKFILL_RETENTION_DAYS, String(retention));
449
+ this.deps.outbox.setState(K.BACKFILL_CURSOR, null);
450
+ this.logger.info('Conversation history backfill to Crewly Cloud finished', { retentionDays: retention });
451
+ return;
452
+ }
453
+ const messages = [];
454
+ let bytes = 0;
455
+ let last = page[0];
456
+ for (const row of page) {
457
+ const message = toIngestUpsert(row, 0);
458
+ if (message) {
459
+ const size = sizeOf(message);
460
+ if (messages.length > 0 && bytes + size > C.BACKFILL_BATCH_MAX_BYTES)
461
+ break;
462
+ messages.push(message);
463
+ bytes += size;
464
+ }
465
+ last = row;
466
+ }
467
+ this.lastBackfillAt = this.now();
468
+ if (messages.length > 0) {
469
+ const outcome = await this.send(base, token, envelope, 'backfill', messages);
470
+ if (!this.settle(outcome))
471
+ return;
472
+ }
473
+ this.deps.outbox.setState(K.BACKFILL_CURSOR, JSON.stringify({ createdAt: last.createdAt, rowid: last.rowid }));
474
+ this.wake(C.BACKFILL_MIN_INTERVAL_MS);
475
+ }
476
+ /**
477
+ * POST one batch (gzip). Never throws.
478
+ *
479
+ * @returns The parsed outcome
480
+ */
481
+ async send(base, token, envelope, mode, messages, extras) {
482
+ const body = {
483
+ ...(extras?.roster ? { roster: extras.roster } : {}),
484
+ ...(extras?.capabilities ? { capabilities: extras.capabilities } : {}),
485
+ instanceId: envelope.instanceId,
486
+ ...(this.deps.homeId ? { homeId: this.deps.homeId } : {}),
487
+ ...(envelope.version ? { crewlyVersion: envelope.version } : {}),
488
+ ...(envelope.deviceName ? { deviceName: envelope.deviceName } : {}),
489
+ batchId: randomUUID(),
490
+ mode,
491
+ messages,
492
+ };
493
+ const url = `${base.replace(/\/$/, '')}${CONVERSATION_INGEST_PATH}`;
494
+ let response;
495
+ try {
496
+ response = await this.fetchImpl(url, {
497
+ method: 'POST',
498
+ headers: {
499
+ Authorization: `Bearer ${token}`,
500
+ 'Content-Type': 'application/json',
501
+ 'Content-Encoding': 'gzip',
502
+ },
503
+ body: gzipSync(Buffer.from(JSON.stringify(body), 'utf8')),
504
+ signal: AbortSignal.timeout(C.REQUEST_TIMEOUT_MS),
505
+ });
506
+ }
507
+ catch (error) {
508
+ return { ok: false, status: 0, code: null, message: error instanceof Error ? error.message : String(error) };
509
+ }
510
+ const parsed = (await response.json().catch(() => null));
511
+ if (response.ok)
512
+ return { ok: true, response: parseIngestResponse(parsed) };
513
+ return {
514
+ ok: false,
515
+ status: response.status,
516
+ code: parseIngestErrorCode(parsed),
517
+ message: `HTTP ${response.status}`,
518
+ };
519
+ }
520
+ /**
521
+ * Book the result of a request: remember the retention window, reset or
522
+ * advance the backoff, pause on the hour-long conditions.
523
+ *
524
+ * @returns True on success
525
+ */
526
+ settle(outcome) {
527
+ if (outcome.ok) {
528
+ this.failures = 0;
529
+ this.pausedUntil = 0;
530
+ this.batchLimit = C.BATCH_MAX_MESSAGES;
531
+ if (this.loggedPauses.size > 0) {
532
+ this.logger.info('Crewly Cloud is taking conversation history again');
533
+ this.loggedPauses.clear();
534
+ }
535
+ const days = outcome.response.retentionDays;
536
+ if (days !== null && days > 0)
537
+ this.deps.outbox.setState(K.RETENTION_DAYS, String(days));
538
+ this.deps.outbox.setState(K.LAST_INGEST_AT, String(this.now()));
539
+ void this.maybeNotifyOwner();
540
+ return true;
541
+ }
542
+ const { status, code } = outcome;
543
+ if (status === 404) {
544
+ this.pause('not_deployed', 'Crewly Cloud has no conversation store yet; checking again in an hour');
545
+ }
546
+ else if (status === 403 && code === INGEST_ERROR_CODES.SYNC_DISABLED) {
547
+ this.pause('sync_disabled', 'Conversation sync is switched off for this Crewly Cloud account; checking again in an hour');
548
+ }
549
+ else if (status === 503 && code === INGEST_ERROR_CODES.KEY_MISSING) {
550
+ this.pause('key_missing', 'Crewly Cloud cannot store conversations right now (key missing); checking again in an hour');
551
+ }
552
+ else if (status === 400) {
553
+ this.pause('invalid_batch', `Crewly Cloud refused a conversation batch (${code ?? 'invalid_batch'}); checking again in an hour`);
554
+ }
555
+ else {
556
+ if (status === 401)
557
+ void this.deps.cloud.tryRefreshToken?.().catch(() => false);
558
+ this.failures += 1;
559
+ const delay = Math.min(C.BACKOFF_INITIAL_MS * 2 ** (this.failures - 1), C.BACKOFF_MAX_MS);
560
+ this.pausedUntil = this.now() + delay;
561
+ if (this.failures === 1 || delay === C.BACKOFF_MAX_MS) {
562
+ this.logger.warn('Conversation upload failed; will retry', { status, code, error: outcome.message, retryInMs: delay });
563
+ }
564
+ this.wake(delay);
565
+ }
566
+ return false;
567
+ }
568
+ /**
569
+ * Pause for an hour, logging the reason once.
570
+ *
571
+ * @param reason - Why
572
+ * @param message - Log line
573
+ */
574
+ pause(reason, message) {
575
+ this.pausedUntil = this.now() + C.UNAVAILABLE_RETRY_MS;
576
+ if (!this.loggedPauses.has(reason)) {
577
+ this.loggedPauses.add(reason);
578
+ this.logger.info(message);
579
+ }
580
+ }
581
+ /**
582
+ * On an account switch, start over: the new account gets this machine's
583
+ * history through a fresh backfill, never the old account's queue.
584
+ *
585
+ * @param token - Current access token
586
+ */
587
+ checkAccount(token) {
588
+ const account = accountIdOfToken(token);
589
+ const stored = this.deps.outbox.getState(K.ACCOUNT_ID);
590
+ if (stored === account)
591
+ return;
592
+ if (stored !== null) {
593
+ this.logger.info('Crewly Cloud account changed; conversation history will be sent again for the new account');
594
+ this.deps.outbox.clear();
595
+ this.deps.outbox.clearState([K.NOTICE_SENT_AT]);
596
+ }
597
+ this.deps.outbox.setState(K.ACCOUNT_ID, account);
598
+ }
599
+ /** Keep the outbox bounded; log a history gap once. */
600
+ trimOutbox() {
601
+ try {
602
+ const dropped = this.deps.outbox.trim({ maxRows: C.OUTBOX_MAX_ROWS, olderThanMs: this.now() - C.OUTBOX_MAX_AGE_MS });
603
+ if (dropped > 0) {
604
+ this.deps.outbox.setState(K.GAP_AT, String(this.now()));
605
+ if (!this.gapLogged) {
606
+ this.gapLogged = true;
607
+ this.logger.warn('Conversation outbox over its limit; oldest rows dropped (history gap on Crewly Cloud)', { dropped });
608
+ }
609
+ }
610
+ }
611
+ catch {
612
+ // trimming is housekeeping
613
+ }
614
+ }
615
+ /** O1: one DM to the owner the first time history reaches Cloud. */
616
+ async maybeNotifyOwner() {
617
+ if (!this.deps.notifyOwner)
618
+ return;
619
+ if (this.deps.outbox.getState(K.NOTICE_SENT_AT))
620
+ return;
621
+ if (this.now() - this.lastNoticeAttemptAt < C.UNAVAILABLE_RETRY_MS && this.lastNoticeAttemptAt > 0)
622
+ return;
623
+ this.lastNoticeAttemptAt = this.now();
624
+ // Claim it first so two quick successes cannot send two DMs.
625
+ this.deps.outbox.setState(K.NOTICE_SENT_AT, String(this.now()));
626
+ let posted = false;
627
+ try {
628
+ const { deviceName } = await this.deps.identity();
629
+ posted = await this.deps.notifyOwner(C.NOTICE_TEXT.replace('{device}', deviceName || 'this machine'));
630
+ }
631
+ catch (error) {
632
+ this.logger.debug('Conversation sync notice not sent', { error: error instanceof Error ? error.message : String(error) });
633
+ }
634
+ if (!posted)
635
+ this.deps.outbox.setState(K.NOTICE_SENT_AT, null);
636
+ }
637
+ }
638
+ let instance = null;
639
+ /**
640
+ * Install the process-wide instance (composition root).
641
+ *
642
+ * @param service - The service, or null
643
+ */
644
+ export function setConversationCloudSyncService(service) {
645
+ instance = service;
646
+ }
647
+ /**
648
+ * The process-wide instance, or null before wiring.
649
+ *
650
+ * @returns The service or null
651
+ */
652
+ export function getConversationCloudSyncService() {
653
+ return instance;
654
+ }
655
+ //# sourceMappingURL=conversation-cloud-sync.service.js.map