crewly 1.20.148 → 1.20.149

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 (53) hide show
  1. package/config/constants.ts +43 -0
  2. package/dist/backend/backend/src/constants.d.ts +19 -0
  3. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  4. package/dist/backend/backend/src/constants.js +2 -1
  5. package/dist/backend/backend/src/constants.js.map +1 -1
  6. package/dist/backend/backend/src/index.d.ts +11 -0
  7. package/dist/backend/backend/src/index.d.ts.map +1 -1
  8. package/dist/backend/backend/src/index.js +57 -1
  9. package/dist/backend/backend/src/index.js.map +1 -1
  10. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.d.ts +200 -0
  11. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.d.ts.map +1 -0
  12. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.js +485 -0
  13. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.js.map +1 -0
  14. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.d.ts +163 -0
  15. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.d.ts.map +1 -0
  16. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.js +176 -0
  17. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.js.map +1 -0
  18. package/dist/backend/backend/src/services/cloud/cloud-login-runner.d.ts +106 -0
  19. package/dist/backend/backend/src/services/cloud/cloud-login-runner.d.ts.map +1 -0
  20. package/dist/backend/backend/src/services/cloud/cloud-login-runner.js +238 -0
  21. package/dist/backend/backend/src/services/cloud/cloud-login-runner.js.map +1 -0
  22. package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts +16 -1
  23. package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -1
  24. package/dist/backend/backend/src/services/cloud/cloud-sync.service.js +38 -0
  25. package/dist/backend/backend/src/services/cloud/cloud-sync.service.js.map +1 -1
  26. package/dist/backend/backend/src/services/cloud/cloud-sync.types.d.ts +14 -0
  27. package/dist/backend/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -1
  28. package/dist/backend/backend/src/services/cloud/cloud-sync.types.js.map +1 -1
  29. package/dist/backend/backend/src/services/slack/slack-owner-direct-dm.d.ts +55 -0
  30. package/dist/backend/backend/src/services/slack/slack-owner-direct-dm.d.ts.map +1 -0
  31. package/dist/backend/backend/src/services/slack/slack-owner-direct-dm.js +114 -0
  32. package/dist/backend/backend/src/services/slack/slack-owner-direct-dm.js.map +1 -0
  33. package/dist/backend/build-info.json +2 -2
  34. package/dist/backend/config/constants.d.ts +42 -0
  35. package/dist/backend/config/constants.d.ts.map +1 -1
  36. package/dist/backend/config/constants.js +42 -0
  37. package/dist/backend/config/constants.js.map +1 -1
  38. package/dist/cli/backend/src/constants.d.ts +19 -0
  39. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  40. package/dist/cli/backend/src/constants.js +2 -1
  41. package/dist/cli/backend/src/constants.js.map +1 -1
  42. package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts +16 -1
  43. package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -1
  44. package/dist/cli/backend/src/services/cloud/cloud-sync.service.js +38 -0
  45. package/dist/cli/backend/src/services/cloud/cloud-sync.service.js.map +1 -1
  46. package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts +14 -0
  47. package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -1
  48. package/dist/cli/backend/src/services/cloud/cloud-sync.types.js.map +1 -1
  49. package/dist/cli/config/constants.d.ts +42 -0
  50. package/dist/cli/config/constants.d.ts.map +1 -1
  51. package/dist/cli/config/constants.js +42 -0
  52. package/dist/cli/config/constants.js.map +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Cloud disconnect notice — tells the owner on Slack when this machine has
3
+ * lost Crewly Cloud, and lets them fix it from their phone.
4
+ *
5
+ * Why: inbound Slack reaches a machine only through Cloud → relay. When a
6
+ * machine's Cloud sign-in expires (or Cloud is unreachable) its agents can
7
+ * still post, but every message to them queues in Cloud unseen. On
8
+ * 2026-09-27 a second Mac sat like that for hours; nobody was told, and the
9
+ * owner — never at the machine — could not fix it remotely.
10
+ *
11
+ * What it does, once a minute:
12
+ * 1. Detect ({@link evaluateDisconnect}): signed in (a Cloud config exists)
13
+ * AND (sync is `auth_expired` OR Cloud has not answered for 15 min).
14
+ * 2. When the sign-in is the problem, run `crewly cloud login --no-browser`
15
+ * in a PTY ({@link startCloudLogin}) — the same device pairing the owner
16
+ * would run by hand — and take the approve link it prints.
17
+ * 3. DM the owner through the Slack Web API with the bot tokens this machine
18
+ * already holds (outbound needs no Cloud), link included.
19
+ * 4. The CLI waits for the approval and saves the credentials itself; then
20
+ * CloudSync is restarted and the owner gets 「已重新连上 Cloud…」.
21
+ * If the CLI asks for typed input, the owner's reply is read straight from
22
+ * the DM (`conversations.history`) — it cannot arrive through Cloud.
23
+ *
24
+ * Rate limit: one notice per disconnect episode, repeated at most every 6 h,
25
+ * persisted under the Crewly home so a restart does not re-notify. An
26
+ * expired link is replaced by editing the same message (no new
27
+ * notification); a failed login gets one brief note and waits for the next
28
+ * window. Kill switch: `CREWLY_CLOUD_DISCONNECT_NOTICE=0`.
29
+ *
30
+ * @module services/cloud/cloud-disconnect-notice.service
31
+ */
32
+ import type { OwnerDirectDm } from '../slack/slack-owner-direct-dm.js';
33
+ import type { CloudSyncHealth } from './cloud-sync.types.js';
34
+ import { type CloudLoginHandle } from './cloud-login-runner.js';
35
+ /** Minimal logger. */
36
+ export interface DisconnectNoticeLogger {
37
+ info(message: string, meta?: Record<string, unknown>): void;
38
+ warn(message: string, meta?: Record<string, unknown>): void;
39
+ }
40
+ /** Everything the service touches outside itself. */
41
+ export interface CloudDisconnectNoticeDeps {
42
+ /** State file (`<crewlyHome>/cloud/disconnect-notice.json`) */
43
+ stateFile: string;
44
+ /** A Cloud config exists (signed in, not logged out) */
45
+ isSignedIn: () => Promise<boolean>;
46
+ /** CloudSyncService health */
47
+ getHealth: () => CloudSyncHealth;
48
+ /** This machine's name */
49
+ getDeviceName: () => Promise<string>;
50
+ /** Owner DM over the bot token, or null when Slack is not configured here */
51
+ getDm: () => OwnerDirectDm | null;
52
+ /** Start `crewly cloud login` in a PTY (throws when it cannot) */
53
+ startLogin: () => CloudLoginHandle;
54
+ /** After a successful CLI login: make sure CloudSync runs on the new credentials */
55
+ reconnect: () => Promise<boolean>;
56
+ logger: DisconnectNoticeLogger;
57
+ now: () => number;
58
+ schedule: (fn: () => void, ms: number) => unknown;
59
+ cancel: (handle: unknown) => void;
60
+ thresholdMs?: number;
61
+ repeatMs?: number;
62
+ checkIntervalMs?: number;
63
+ linkWaitMs?: number;
64
+ loginRetryMs?: number;
65
+ replyPollMs?: number;
66
+ replyMaxWaitMs?: number;
67
+ }
68
+ /** Watches the Cloud connection and DMs the owner when it is lost. */
69
+ export declare class CloudDisconnectNoticeService {
70
+ private readonly deps;
71
+ private readonly thresholdMs;
72
+ private readonly repeatMs;
73
+ private readonly checkIntervalMs;
74
+ private readonly linkWaitMs;
75
+ private readonly loginRetryMs;
76
+ private readonly replyPollMs;
77
+ private readonly replyMaxWaitMs;
78
+ private monitorStartedAt;
79
+ private timer;
80
+ private stopped;
81
+ private ticking;
82
+ private login;
83
+ private lastLoginAttemptAt;
84
+ private replyTimer;
85
+ private replyDeadline;
86
+ private lastReplyTs;
87
+ /** Where the current notice is (for reading the owner's replies) */
88
+ private notice;
89
+ /** Serialises checks and login outcomes so neither overwrites the other's state */
90
+ private chain;
91
+ /**
92
+ * @param deps - Collaborators (see {@link CloudDisconnectNoticeDeps})
93
+ */
94
+ constructor(deps: CloudDisconnectNoticeDeps);
95
+ /** Start checking every minute. */
96
+ start(): void;
97
+ /** Stop checking and kill any login run. */
98
+ stop(): void;
99
+ /**
100
+ * One check. Public for tests; the timer calls it every minute.
101
+ *
102
+ * @returns Resolves when the check (and any notice) is done
103
+ */
104
+ tick(): Promise<void>;
105
+ /**
106
+ * Run `fn` after every earlier check / login outcome has finished.
107
+ *
108
+ * @param fn - Work that reads and writes the state file
109
+ * @returns Resolves when `fn` is done (never rejects)
110
+ */
111
+ private serial;
112
+ /**
113
+ * The check itself: detect, then notify, refresh the link, or close the episode.
114
+ */
115
+ private check;
116
+ /**
117
+ * Send the notice (first of the episode, or the 6-hourly repeat).
118
+ *
119
+ * @param state - Episode state (updated in place)
120
+ * @param reason - Why
121
+ */
122
+ private sendNotice;
123
+ /**
124
+ * Whether to start a new login run and put its link into the notice:
125
+ * sign-in problem, a notice to edit, no live run, not blocked by a failed
126
+ * login, and not retried within the last few minutes.
127
+ *
128
+ * @param state - Episode state
129
+ * @param now - Now (epoch ms)
130
+ * @returns True to refresh
131
+ */
132
+ private shouldRefreshLink;
133
+ /**
134
+ * Start a login run and edit the notice to carry its link (the link in it
135
+ * is missing or has expired). Editing does not notify the owner again.
136
+ *
137
+ * @param state - Episode state (updated in place)
138
+ */
139
+ private refreshLink;
140
+ /**
141
+ * Start (or reuse) a CLI login run and wait briefly for its link.
142
+ *
143
+ * @returns The run's snapshot with a link, or null when none came
144
+ */
145
+ private ensureLoginLink;
146
+ /**
147
+ * A login run ended.
148
+ *
149
+ * @param handle - The run
150
+ * @param snapshot - How it ended
151
+ */
152
+ private onLoginFinished;
153
+ /**
154
+ * Tell the owner briefly that the login did not finish, and hold further
155
+ * login runs until the next notice window.
156
+ *
157
+ * @param state - Episode state (updated in place)
158
+ * @param detail - Owner-facing reason
159
+ */
160
+ private blockUntilNextWindow;
161
+ /**
162
+ * The episode is over: forget it and, when the owner was told and the
163
+ * machine is back, say so.
164
+ *
165
+ * @param reconnected - Back on Cloud (false = logged out on purpose)
166
+ */
167
+ private endEpisode;
168
+ /** Read the owner's DM replies while the CLI waits for typed input. */
169
+ private startReplyPolling;
170
+ /** Stop reading replies. */
171
+ private stopReplyPolling;
172
+ /**
173
+ * This machine's name (hostname fallback is the caller's job).
174
+ *
175
+ * @returns The name
176
+ */
177
+ private deviceName;
178
+ /** Schedule the next check. */
179
+ private scheduleNext;
180
+ }
181
+ /** Live hooks the server provides to {@link createCloudDisconnectNoticeService}. */
182
+ export interface CloudDisconnectNoticeWiring {
183
+ /** Crewly home (the state file lives here) */
184
+ crewlyHome: string;
185
+ /** Owner DM over this machine's bot token, or null when Slack is not configured */
186
+ getDm: () => OwnerDirectDm | null;
187
+ /** Restart CloudSync on the credentials the CLI saved */
188
+ reconnect: () => Promise<boolean>;
189
+ /** This machine's name */
190
+ getDeviceName: () => Promise<string>;
191
+ }
192
+ /**
193
+ * Build the service with the real Cloud config, CloudSync health, CLI login,
194
+ * clock and timers.
195
+ *
196
+ * @param wiring - Server hooks
197
+ * @returns The service (not started)
198
+ */
199
+ export declare function createCloudDisconnectNoticeService(wiring: CloudDisconnectNoticeWiring): CloudDisconnectNoticeService;
200
+ //# sourceMappingURL=cloud-disconnect-notice.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloud-disconnect-notice.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/cloud/cloud-disconnect-notice.service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Cloud disconnect notice — tells the owner on Slack when this machine has
3
+ * lost Crewly Cloud, and lets them fix it from their phone.
4
+ *
5
+ * Why: inbound Slack reaches a machine only through Cloud → relay. When a
6
+ * machine's Cloud sign-in expires (or Cloud is unreachable) its agents can
7
+ * still post, but every message to them queues in Cloud unseen. On
8
+ * 2026-09-27 a second Mac sat like that for hours; nobody was told, and the
9
+ * owner — never at the machine — could not fix it remotely.
10
+ *
11
+ * What it does, once a minute:
12
+ * 1. Detect ({@link evaluateDisconnect}): signed in (a Cloud config exists)
13
+ * AND (sync is `auth_expired` OR Cloud has not answered for 15 min).
14
+ * 2. When the sign-in is the problem, run `crewly cloud login --no-browser`
15
+ * in a PTY ({@link startCloudLogin}) — the same device pairing the owner
16
+ * would run by hand — and take the approve link it prints.
17
+ * 3. DM the owner through the Slack Web API with the bot tokens this machine
18
+ * already holds (outbound needs no Cloud), link included.
19
+ * 4. The CLI waits for the approval and saves the credentials itself; then
20
+ * CloudSync is restarted and the owner gets 「已重新连上 Cloud…」.
21
+ * If the CLI asks for typed input, the owner's reply is read straight from
22
+ * the DM (`conversations.history`) — it cannot arrive through Cloud.
23
+ *
24
+ * Rate limit: one notice per disconnect episode, repeated at most every 6 h,
25
+ * persisted under the Crewly home so a restart does not re-notify. An
26
+ * expired link is replaced by editing the same message (no new
27
+ * notification); a failed login gets one brief note and waits for the next
28
+ * window. Kill switch: `CREWLY_CLOUD_DISCONNECT_NOTICE=0`.
29
+ *
30
+ * @module services/cloud/cloud-disconnect-notice.service
31
+ */
32
+ import * as fs from 'fs';
33
+ import * as path from 'path';
34
+ import { CLOUD_DISCONNECT_NOTICE_CONSTANTS } from '../../../../config/constants.js';
35
+ import { LoggerService } from '../core/logger.service.js';
36
+ import { resolveRunningPackageRoot } from '../system/auto-update.utils.js';
37
+ import { CloudClientService } from './cloud-client.service.js';
38
+ import { CloudSyncService } from './cloud-sync.service.js';
39
+ import { isCloudLoginFinished, startCloudLogin } from './cloud-login-runner.js';
40
+ import { RECONNECTED_NOTICE, clearNoticeState, composeDisconnectNotice, composeLoginFailedNotice, evaluateDisconnect, readNoticeState, shouldNotify, writeNoticeState, } from './cloud-disconnect-notice.utils.js';
41
+ const C = CLOUD_DISCONNECT_NOTICE_CONSTANTS;
42
+ /**
43
+ * Owner-facing text for how a login run ended.
44
+ *
45
+ * @param snapshot - The finished run
46
+ * @returns Short Chinese reason
47
+ */
48
+ function loginFailureDetail(snapshot) {
49
+ if (snapshot.state === 'timed_out')
50
+ return '等待超时';
51
+ if (snapshot.state === 'expired')
52
+ return '链接已过期';
53
+ if (snapshot.message && /denied/i.test(snapshot.message))
54
+ return '登录被拒绝';
55
+ return '登录命令出错';
56
+ }
57
+ /** Watches the Cloud connection and DMs the owner when it is lost. */
58
+ export class CloudDisconnectNoticeService {
59
+ deps;
60
+ thresholdMs;
61
+ repeatMs;
62
+ checkIntervalMs;
63
+ linkWaitMs;
64
+ loginRetryMs;
65
+ replyPollMs;
66
+ replyMaxWaitMs;
67
+ monitorStartedAt = 0;
68
+ timer = null;
69
+ stopped = true;
70
+ ticking = false;
71
+ login = null;
72
+ lastLoginAttemptAt = 0;
73
+ replyTimer = null;
74
+ replyDeadline = 0;
75
+ lastReplyTs = null;
76
+ /** Where the current notice is (for reading the owner's replies) */
77
+ notice = null;
78
+ /** Serialises checks and login outcomes so neither overwrites the other's state */
79
+ chain = Promise.resolve();
80
+ /**
81
+ * @param deps - Collaborators (see {@link CloudDisconnectNoticeDeps})
82
+ */
83
+ constructor(deps) {
84
+ this.deps = deps;
85
+ this.thresholdMs = deps.thresholdMs ?? C.DISCONNECT_THRESHOLD_MS;
86
+ this.repeatMs = deps.repeatMs ?? C.REPEAT_INTERVAL_MS;
87
+ this.checkIntervalMs = deps.checkIntervalMs ?? C.CHECK_INTERVAL_MS;
88
+ this.linkWaitMs = deps.linkWaitMs ?? C.LINK_WAIT_MS;
89
+ this.loginRetryMs = deps.loginRetryMs ?? C.LOGIN_RETRY_MS;
90
+ this.replyPollMs = deps.replyPollMs ?? C.REPLY_POLL_INTERVAL_MS;
91
+ this.replyMaxWaitMs = deps.replyMaxWaitMs ?? C.REPLY_MAX_WAIT_MS;
92
+ }
93
+ /** Start checking every minute. */
94
+ start() {
95
+ if (!this.stopped)
96
+ return;
97
+ this.stopped = false;
98
+ this.monitorStartedAt = this.deps.now();
99
+ this.scheduleNext();
100
+ }
101
+ /** Stop checking and kill any login run. */
102
+ stop() {
103
+ this.stopped = true;
104
+ if (this.timer !== null)
105
+ this.deps.cancel(this.timer);
106
+ this.timer = null;
107
+ this.stopReplyPolling();
108
+ this.login?.cancel();
109
+ this.login = null;
110
+ }
111
+ /**
112
+ * One check. Public for tests; the timer calls it every minute.
113
+ *
114
+ * @returns Resolves when the check (and any notice) is done
115
+ */
116
+ async tick() {
117
+ if (this.ticking)
118
+ return;
119
+ this.ticking = true;
120
+ try {
121
+ await this.serial(() => this.check());
122
+ }
123
+ finally {
124
+ this.ticking = false;
125
+ }
126
+ }
127
+ /**
128
+ * Run `fn` after every earlier check / login outcome has finished.
129
+ *
130
+ * @param fn - Work that reads and writes the state file
131
+ * @returns Resolves when `fn` is done (never rejects)
132
+ */
133
+ serial(fn) {
134
+ const run = this.chain.then(fn).catch((error) => {
135
+ this.deps.logger.warn('Cloud disconnect check failed', { error: error instanceof Error ? error.message : String(error) });
136
+ });
137
+ this.chain = run;
138
+ return run;
139
+ }
140
+ /**
141
+ * The check itself: detect, then notify, refresh the link, or close the episode.
142
+ */
143
+ async check() {
144
+ const now = this.deps.now();
145
+ const verdict = evaluateDisconnect({
146
+ signedIn: await this.deps.isSignedIn(),
147
+ health: this.deps.getHealth(),
148
+ monitorStartedAt: this.monitorStartedAt || now,
149
+ now,
150
+ thresholdMs: this.thresholdMs,
151
+ });
152
+ const state = readNoticeState(this.deps.stateFile);
153
+ if (verdict.status === 'signed_out') {
154
+ // Logged out on purpose: nothing to tell, nothing to fix.
155
+ if (state)
156
+ await this.endEpisode(false);
157
+ return;
158
+ }
159
+ if (verdict.status === 'connected') {
160
+ if (state)
161
+ await this.endEpisode(true);
162
+ return;
163
+ }
164
+ if (verdict.status !== 'disconnected')
165
+ return;
166
+ const current = state ?? {
167
+ episodeStartedAt: new Date(verdict.since).toISOString(),
168
+ reason: verdict.reason,
169
+ lastNotifiedAt: null,
170
+ };
171
+ current.reason = verdict.reason;
172
+ if (current.channelId && current.messageTs)
173
+ this.notice = { channelId: current.channelId, ts: current.messageTs };
174
+ if (shouldNotify(state, now, this.repeatMs)) {
175
+ await this.sendNotice(current, verdict.reason);
176
+ }
177
+ else if (this.shouldRefreshLink(current, now)) {
178
+ await this.refreshLink(current);
179
+ }
180
+ writeNoticeState(this.deps.stateFile, current);
181
+ }
182
+ /**
183
+ * Send the notice (first of the episode, or the 6-hourly repeat).
184
+ *
185
+ * @param state - Episode state (updated in place)
186
+ * @param reason - Why
187
+ */
188
+ async sendNotice(state, reason) {
189
+ const now = this.deps.now();
190
+ const dm = this.deps.getDm();
191
+ if (!dm) {
192
+ this.deps.logger.warn('Disconnected from Crewly Cloud; Slack is not configured here, so the owner was not notified', { reason });
193
+ state.lastNotifiedAt = new Date(now).toISOString();
194
+ return;
195
+ }
196
+ // A new window lifts the block a failed login set.
197
+ delete state.loginBlockedUntil;
198
+ const link = reason === 'auth' ? await this.ensureLoginLink() : null;
199
+ const text = composeDisconnectNotice({
200
+ deviceName: await this.deviceName(),
201
+ reason,
202
+ since: Date.parse(state.episodeStartedAt),
203
+ loginUrl: link?.url ?? null,
204
+ userCode: link?.userCode ?? null,
205
+ });
206
+ try {
207
+ const posted = await dm.send(text);
208
+ state.lastNotifiedAt = new Date(now).toISOString();
209
+ state.channelId = posted.channelId;
210
+ state.messageTs = posted.ts;
211
+ state.hasLink = !!link?.url;
212
+ this.notice = { channelId: posted.channelId, ts: posted.ts };
213
+ this.lastReplyTs = posted.ts;
214
+ this.deps.logger.info('Owner told that this machine is disconnected from Crewly Cloud', { reason, withLink: state.hasLink });
215
+ if (this.login?.get().needsInput)
216
+ this.startReplyPolling();
217
+ }
218
+ catch (error) {
219
+ // Not recorded as notified: the next check tries again.
220
+ this.deps.logger.warn('Could not DM the owner about the Cloud disconnect', { error: error instanceof Error ? error.message : String(error) });
221
+ }
222
+ }
223
+ /**
224
+ * Whether to start a new login run and put its link into the notice:
225
+ * sign-in problem, a notice to edit, no live run, not blocked by a failed
226
+ * login, and not retried within the last few minutes.
227
+ *
228
+ * @param state - Episode state
229
+ * @param now - Now (epoch ms)
230
+ * @returns True to refresh
231
+ */
232
+ shouldRefreshLink(state, now) {
233
+ if (state.reason !== 'auth' || !state.channelId || !state.messageTs)
234
+ return false;
235
+ if (this.login && !isCloudLoginFinished(this.login.get().state))
236
+ return false;
237
+ if (state.loginBlockedUntil && Date.parse(state.loginBlockedUntil) > now)
238
+ return false;
239
+ return now - this.lastLoginAttemptAt >= this.loginRetryMs;
240
+ }
241
+ /**
242
+ * Start a login run and edit the notice to carry its link (the link in it
243
+ * is missing or has expired). Editing does not notify the owner again.
244
+ *
245
+ * @param state - Episode state (updated in place)
246
+ */
247
+ async refreshLink(state) {
248
+ const dm = this.deps.getDm();
249
+ if (!dm || !state.channelId || !state.messageTs)
250
+ return;
251
+ const link = await this.ensureLoginLink();
252
+ if (!link?.url)
253
+ return;
254
+ const text = composeDisconnectNotice({
255
+ deviceName: await this.deviceName(),
256
+ reason: 'auth',
257
+ since: Date.parse(state.episodeStartedAt),
258
+ loginUrl: link.url,
259
+ userCode: link.userCode,
260
+ });
261
+ try {
262
+ await dm.update(state.channelId, state.messageTs, text);
263
+ state.hasLink = true;
264
+ this.deps.logger.info('Cloud re-login link in the owner notice refreshed');
265
+ }
266
+ catch (error) {
267
+ this.deps.logger.warn('Could not update the Cloud disconnect notice with a new link', { error: error instanceof Error ? error.message : String(error) });
268
+ this.login?.cancel();
269
+ await this.blockUntilNextWindow(state, '链接已过期');
270
+ }
271
+ }
272
+ /**
273
+ * Start (or reuse) a CLI login run and wait briefly for its link.
274
+ *
275
+ * @returns The run's snapshot with a link, or null when none came
276
+ */
277
+ async ensureLoginLink() {
278
+ if (!this.login || isCloudLoginFinished(this.login.get().state)) {
279
+ this.lastLoginAttemptAt = this.deps.now();
280
+ let handle;
281
+ try {
282
+ handle = this.deps.startLogin();
283
+ }
284
+ catch (error) {
285
+ this.deps.logger.warn('Could not start `crewly cloud login`', { error: error instanceof Error ? error.message : String(error) });
286
+ return null;
287
+ }
288
+ this.login = handle;
289
+ handle.onChange((snapshot) => {
290
+ if (snapshot.needsInput)
291
+ this.startReplyPolling();
292
+ });
293
+ void handle.done.then((snapshot) => this.serial(() => this.onLoginFinished(handle, snapshot)));
294
+ }
295
+ const snapshot = await this.login.waitForLink(this.linkWaitMs);
296
+ return snapshot.url && !isCloudLoginFinished(snapshot.state) ? snapshot : null;
297
+ }
298
+ /**
299
+ * A login run ended.
300
+ *
301
+ * @param handle - The run
302
+ * @param snapshot - How it ended
303
+ */
304
+ async onLoginFinished(handle, snapshot) {
305
+ if (this.login === handle)
306
+ this.login = null;
307
+ this.stopReplyPolling();
308
+ if (this.stopped || snapshot.state === 'cancelled')
309
+ return;
310
+ if (snapshot.state === 'succeeded') {
311
+ let ok = false;
312
+ try {
313
+ ok = await this.deps.reconnect();
314
+ }
315
+ catch (error) {
316
+ this.deps.logger.warn('Cloud login succeeded but reconnecting failed', { error: error instanceof Error ? error.message : String(error) });
317
+ }
318
+ if (ok) {
319
+ this.deps.logger.info('Reconnected to Crewly Cloud after the owner signed in again');
320
+ await this.endEpisode(true);
321
+ }
322
+ return;
323
+ }
324
+ const state = readNoticeState(this.deps.stateFile);
325
+ if (!state?.lastNotifiedAt)
326
+ return;
327
+ // An expired link is replaced in place by the next check when the
328
+ // notice can be edited; anything else waits for the next window.
329
+ if (snapshot.state === 'expired' && state.channelId && state.messageTs) {
330
+ state.hasLink = false;
331
+ writeNoticeState(this.deps.stateFile, state);
332
+ return;
333
+ }
334
+ this.deps.logger.info('Cloud re-login did not finish', { state: snapshot.state });
335
+ await this.blockUntilNextWindow(state, loginFailureDetail(snapshot));
336
+ writeNoticeState(this.deps.stateFile, state);
337
+ }
338
+ /**
339
+ * Tell the owner briefly that the login did not finish, and hold further
340
+ * login runs until the next notice window.
341
+ *
342
+ * @param state - Episode state (updated in place)
343
+ * @param detail - Owner-facing reason
344
+ */
345
+ async blockUntilNextWindow(state, detail) {
346
+ const last = state.lastNotifiedAt ? Date.parse(state.lastNotifiedAt) : this.deps.now();
347
+ const retryAt = last + this.repeatMs;
348
+ state.loginBlockedUntil = new Date(retryAt).toISOString();
349
+ state.hasLink = false;
350
+ const dm = this.deps.getDm();
351
+ if (!dm)
352
+ return;
353
+ try {
354
+ await dm.send(composeLoginFailedNotice(detail, retryAt));
355
+ }
356
+ catch (error) {
357
+ this.deps.logger.warn('Could not tell the owner the Cloud re-login did not finish', { error: error instanceof Error ? error.message : String(error) });
358
+ }
359
+ }
360
+ /**
361
+ * The episode is over: forget it and, when the owner was told and the
362
+ * machine is back, say so.
363
+ *
364
+ * @param reconnected - Back on Cloud (false = logged out on purpose)
365
+ */
366
+ async endEpisode(reconnected) {
367
+ const state = readNoticeState(this.deps.stateFile);
368
+ clearNoticeState(this.deps.stateFile);
369
+ this.notice = null;
370
+ this.lastReplyTs = null;
371
+ this.stopReplyPolling();
372
+ if (this.login && !isCloudLoginFinished(this.login.get().state))
373
+ this.login.cancel();
374
+ this.login = null;
375
+ this.lastLoginAttemptAt = 0;
376
+ if (!reconnected || !state?.lastNotifiedAt)
377
+ return;
378
+ const dm = this.deps.getDm();
379
+ if (!dm)
380
+ return;
381
+ try {
382
+ await dm.send(RECONNECTED_NOTICE);
383
+ this.deps.logger.info('Owner told that this machine is back on Crewly Cloud');
384
+ }
385
+ catch (error) {
386
+ this.deps.logger.warn('Could not tell the owner the machine is back on Cloud', { error: error instanceof Error ? error.message : String(error) });
387
+ }
388
+ }
389
+ /** Read the owner's DM replies while the CLI waits for typed input. */
390
+ startReplyPolling() {
391
+ if (this.replyTimer !== null || this.stopped || !this.notice)
392
+ return;
393
+ this.replyDeadline = this.deps.now() + this.replyMaxWaitMs;
394
+ const { channelId, ts: noticeTs } = this.notice;
395
+ const poll = async () => {
396
+ this.replyTimer = null;
397
+ const login = this.login;
398
+ if (this.stopped || !login || !login.get().needsInput)
399
+ return;
400
+ if (this.deps.now() >= this.replyDeadline) {
401
+ this.deps.logger.info('Stopped waiting for the owner to answer the Cloud login in Slack');
402
+ return;
403
+ }
404
+ try {
405
+ const dm = this.deps.getDm();
406
+ const replies = dm ? await dm.readOwnerReplies(channelId, this.lastReplyTs ?? noticeTs) : [];
407
+ const reply = replies[replies.length - 1];
408
+ if (reply) {
409
+ this.lastReplyTs = reply.ts;
410
+ // Never log the reply: it may be a token.
411
+ login.input(reply.text);
412
+ this.deps.logger.info('Owner reply typed into `crewly cloud login`');
413
+ return;
414
+ }
415
+ }
416
+ catch (error) {
417
+ this.deps.logger.warn('Could not read the owner DM for a login reply', { error: error instanceof Error ? error.message : String(error) });
418
+ }
419
+ this.replyTimer = this.deps.schedule(() => void poll(), this.replyPollMs);
420
+ };
421
+ this.replyTimer = this.deps.schedule(() => void poll(), this.replyPollMs);
422
+ }
423
+ /** Stop reading replies. */
424
+ stopReplyPolling() {
425
+ if (this.replyTimer !== null)
426
+ this.deps.cancel(this.replyTimer);
427
+ this.replyTimer = null;
428
+ }
429
+ /**
430
+ * This machine's name (hostname fallback is the caller's job).
431
+ *
432
+ * @returns The name
433
+ */
434
+ async deviceName() {
435
+ try {
436
+ return await this.deps.getDeviceName();
437
+ }
438
+ catch {
439
+ return 'unknown';
440
+ }
441
+ }
442
+ /** Schedule the next check. */
443
+ scheduleNext() {
444
+ if (this.stopped)
445
+ return;
446
+ this.timer = this.deps.schedule(() => {
447
+ this.timer = null;
448
+ void this.tick().finally(() => this.scheduleNext());
449
+ }, this.checkIntervalMs);
450
+ }
451
+ }
452
+ /**
453
+ * Build the service with the real Cloud config, CloudSync health, CLI login,
454
+ * clock and timers.
455
+ *
456
+ * @param wiring - Server hooks
457
+ * @returns The service (not started)
458
+ */
459
+ export function createCloudDisconnectNoticeService(wiring) {
460
+ const logger = LoggerService.getInstance().createComponentLogger('CloudDisconnectNotice');
461
+ return new CloudDisconnectNoticeService({
462
+ stateFile: path.join(wiring.crewlyHome, C.STATE_FILE),
463
+ isSignedIn: async () => fs.existsSync(CloudClientService.getConfigPath()),
464
+ getHealth: () => CloudSyncService.getInstance().getHealth(),
465
+ getDeviceName: wiring.getDeviceName,
466
+ getDm: wiring.getDm,
467
+ startLogin: () => {
468
+ const root = resolveRunningPackageRoot(process.argv[1]);
469
+ const cliEntry = root ? path.join(root, C.CLI_ENTRY) : null;
470
+ if (!cliEntry || !fs.existsSync(cliEntry))
471
+ throw new Error('crewly CLI not found next to the running backend');
472
+ return startCloudLogin({ cliEntry });
473
+ },
474
+ reconnect: wiring.reconnect,
475
+ logger,
476
+ now: Date.now,
477
+ schedule: (fn, ms) => {
478
+ const t = setTimeout(fn, ms);
479
+ t.unref?.();
480
+ return t;
481
+ },
482
+ cancel: (handle) => clearTimeout(handle),
483
+ });
484
+ }
485
+ //# sourceMappingURL=cloud-disconnect-notice.service.js.map