@gajae-code/coding-agent 0.12.7 → 0.12.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +23 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-discovery-manager.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/resolve-config-value.d.ts +2 -1
- package/dist/types/config/settings-schema.d.ts +17 -1
- package/dist/types/discovery/agents-md.d.ts +11 -1
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +20 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/sdk/broker/broker.d.ts +5 -1
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/config.d.ts +75 -113
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
- package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
- package/dist/types/sdk/bus/notification-service.d.ts +54 -4
- package/dist/types/sdk/bus/session-control.d.ts +8 -3
- package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
- package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/client/discovery.d.ts +2 -1
- package/dist/types/sdk/host/query/handlers.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/providers.d.ts +22 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
- package/dist/types/session/internal/session-open-errors.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/session/session-storage.d.ts +2 -0
- package/dist/types/system-prompt.d.ts +12 -1
- package/dist/types/tools/composer-bash-policy.d.ts +9 -1
- package/package.json +10 -8
- package/scripts/build-sdk-package-smoke.ts +51 -0
- package/scripts/capture-notifications-settings-showcase.ts +1 -1
- package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
- package/src/cli/notify-cli.ts +215 -99
- package/src/commands/notify.ts +19 -8
- package/src/commands/sdk.ts +54 -0
- package/src/config/file-lock.ts +6 -0
- package/src/config/model-discovery-manager.ts +50 -8
- package/src/config/model-profiles.ts +8 -0
- package/src/config/model-registry.ts +866 -76
- package/src/config/resolve-config-value.ts +10 -8
- package/src/config/settings-schema.ts +25 -15
- package/src/config/settings.ts +15 -15
- package/src/discovery/agents-md.ts +76 -19
- package/src/gjc-runtime/psmux-detect.ts +12 -16
- package/src/gjc-runtime/session-state-sidecar.ts +12 -2
- package/src/gjc-runtime/tmux-sessions.ts +8 -1
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/main.ts +43 -7
- package/src/modes/DESIGN.md +11 -11
- package/src/modes/components/extensions/inspector-panel.ts +9 -5
- package/src/modes/components/hook-selector.ts +155 -44
- package/src/modes/components/model-selector.ts +242 -49
- package/src/modes/components/notifications-settings-editor.ts +631 -85
- package/src/modes/controllers/extension-ui-controller.ts +114 -15
- package/src/modes/controllers/selector-controller.ts +525 -93
- package/src/modes/print-mode.ts +3 -0
- package/src/modes/types.ts +6 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/sdk/broker/broker.ts +54 -10
- package/src/sdk/broker/lifecycle-ledger.ts +34 -1
- package/src/sdk/broker/lifecycle.ts +47 -50
- package/src/sdk/bus/chat-daemon-cli.ts +12 -5
- package/src/sdk/bus/chat-daemon-control.ts +35 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
- package/src/sdk/bus/config.ts +534 -286
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/discord-live-provider.ts +57 -2
- package/src/sdk/bus/discord-provider.ts +21 -0
- package/src/sdk/bus/index.ts +97 -35
- package/src/sdk/bus/notification-orchestration.ts +371 -68
- package/src/sdk/bus/notification-service.ts +571 -137
- package/src/sdk/bus/session-control.ts +119 -44
- package/src/sdk/bus/slack-live-provider.ts +78 -2
- package/src/sdk/bus/slack-provider.ts +24 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/bus/telegram-daemon-control.ts +3 -3
- package/src/sdk/bus/telegram-daemon.ts +23 -15
- package/src/sdk/client/discovery.ts +10 -4
- package/src/sdk/host/query/handlers.ts +20 -1
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +5 -2
- package/src/sdk/providers.ts +51 -0
- package/src/sdk/session-directory.ts +1 -0
- package/src/sdk/session.ts +34 -14
- package/src/session/agent-session.ts +109 -37
- package/src/session/internal/managed-session-scope.ts +981 -450
- package/src/session/internal/managed-session-storage.ts +418 -47
- package/src/session/internal/native-publish-outcome.ts +15 -3
- package/src/session/internal/session-open-errors.ts +11 -0
- package/src/session/session-manager.ts +27 -3
- package/src/session/session-storage.ts +72 -5
- package/src/slash-commands/builtin-registry.ts +117 -15
- package/src/system-prompt.ts +28 -15
- package/src/task/index.ts +9 -3
- package/src/tools/ask.ts +1 -1
- package/src/tools/bash.ts +4 -2
- package/src/tools/composer-bash-policy.ts +21 -5
- package/src/tools/telegram-send.ts +4 -2
- package/src/utils/command-args.ts +19 -30
- package/src/utils/lang-from-path.ts +2 -4
|
@@ -20,14 +20,25 @@ import type { Settings } from "../../config/settings";
|
|
|
20
20
|
import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
|
|
21
21
|
import {
|
|
22
22
|
getNotificationConfig,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
hasAnyCompleteProvider,
|
|
24
|
+
hasAnyEffectivelyEnabledProvider,
|
|
25
|
+
isDiscordComplete,
|
|
26
|
+
isProviderEffectivelyEnabled,
|
|
27
|
+
isSlackComplete,
|
|
28
|
+
isTelegramComplete,
|
|
26
29
|
maskToken,
|
|
27
30
|
type NotificationConfig,
|
|
31
|
+
type NotificationProvider,
|
|
32
|
+
type NotificationRuntime,
|
|
33
|
+
type ProviderResolution,
|
|
34
|
+
resolveNotificationProvider,
|
|
28
35
|
tokenFingerprint,
|
|
29
36
|
} from "./config";
|
|
30
37
|
import { type DaemonPaths, daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
|
|
38
|
+
import { DiscordLiveProvider } from "./discord-live-provider";
|
|
39
|
+
import type { DiscordDiagnosticProvider } from "./discord-provider";
|
|
40
|
+
import { SlackLiveProvider } from "./slack-live-provider";
|
|
41
|
+
import type { SlackDiagnosticProvider } from "./slack-provider";
|
|
31
42
|
import { type OwnerFreshnessSnapshot, readOwnerFreshnessSnapshot, type TelegramDaemonFs } from "./telegram-daemon";
|
|
32
43
|
import { DAEMON_GENERATION } from "./telegram-daemon-contract";
|
|
33
44
|
|
|
@@ -52,6 +63,61 @@ export function sanitizeDiagnostic(text: string, token?: string): string {
|
|
|
52
63
|
return out.replace(TELEGRAM_TOKEN_PATTERN, "<redacted>");
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
export interface NotificationDiagnosticEvent {
|
|
67
|
+
timestamp: string;
|
|
68
|
+
operation: string;
|
|
69
|
+
phase: string;
|
|
70
|
+
outcome: string;
|
|
71
|
+
reason: string;
|
|
72
|
+
pid?: number;
|
|
73
|
+
incarnation?: string;
|
|
74
|
+
ageMs?: number;
|
|
75
|
+
detail?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Append a bounded, private, secret-safe daemon diagnostic event. */
|
|
79
|
+
export async function writeNotificationDiagnostic(
|
|
80
|
+
settings: Pick<Settings, "getAgentDir">,
|
|
81
|
+
event: Omit<NotificationDiagnosticEvent, "timestamp"> & { timestamp?: string },
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const file = daemonPaths(settings.getAgentDir()).diagnostic;
|
|
84
|
+
const dir = path.dirname(file);
|
|
85
|
+
let events: NotificationDiagnosticEvent[] = [];
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(await fsPromises.readFile(file, "utf8")) as { events?: unknown };
|
|
88
|
+
if (Array.isArray(parsed.events)) events = parsed.events as NotificationDiagnosticEvent[];
|
|
89
|
+
} catch {
|
|
90
|
+
// A missing or corrupt diagnostic history must not block notification recovery.
|
|
91
|
+
}
|
|
92
|
+
const safe: NotificationDiagnosticEvent = {
|
|
93
|
+
timestamp: event.timestamp ?? new Date().toISOString(),
|
|
94
|
+
operation: event.operation.slice(0, 80),
|
|
95
|
+
phase: event.phase.slice(0, 80),
|
|
96
|
+
outcome: event.outcome.slice(0, 80),
|
|
97
|
+
reason: event.reason.slice(0, 120),
|
|
98
|
+
...(event.pid === undefined ? {} : { pid: event.pid }),
|
|
99
|
+
...(event.incarnation === undefined ? {} : { incarnation: event.incarnation.slice(0, 120) }),
|
|
100
|
+
...(event.ageMs === undefined ? {} : { ageMs: Math.max(0, Math.floor(event.ageMs)) }),
|
|
101
|
+
...(event.detail === undefined ? {} : { detail: sanitizeDiagnostic(event.detail).slice(0, 512) }),
|
|
102
|
+
};
|
|
103
|
+
await fsPromises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
104
|
+
await fsPromises.writeFile(file, `${JSON.stringify({ version: 1, events: [...events, safe].slice(-64) })}\n`, {
|
|
105
|
+
mode: 0o600,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sanitizeProviderDiagnostic(text: string, cfg: NotificationConfig, provider: NotificationProvider): string {
|
|
110
|
+
const secrets =
|
|
111
|
+
provider === "telegram"
|
|
112
|
+
? [cfg.botToken]
|
|
113
|
+
: provider === "discord"
|
|
114
|
+
? [cfg.discord.botToken]
|
|
115
|
+
: [cfg.slack.botToken, cfg.slack.appToken];
|
|
116
|
+
let detail = text;
|
|
117
|
+
for (const secret of secrets) detail = sanitizeDiagnostic(detail, secret);
|
|
118
|
+
return detail;
|
|
119
|
+
}
|
|
120
|
+
|
|
55
121
|
/** Identity evidence required to remove precisely the endpoint that was inspected. */
|
|
56
122
|
export interface NotificationEndpointFileIdentity {
|
|
57
123
|
dev: bigint;
|
|
@@ -159,8 +225,12 @@ export interface NotificationServiceDeps {
|
|
|
159
225
|
fs?: NotificationServiceFs;
|
|
160
226
|
now?: () => number;
|
|
161
227
|
pidAlive?: (pid: number) => boolean;
|
|
228
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
162
229
|
fetchImpl?: typeof fetch;
|
|
163
230
|
apiBase?: string;
|
|
231
|
+
createDiscordDiagnostic?: (config: { applicationId: string; botToken: string }) => DiscordDiagnosticProvider;
|
|
232
|
+
createSlackDiagnostic?: (config: { appToken: string; botToken: string }) => SlackDiagnosticProvider;
|
|
233
|
+
providerRuntimeStatus?: (provider: NotificationProvider) => Promise<NotificationRuntime> | NotificationRuntime;
|
|
164
234
|
}
|
|
165
235
|
|
|
166
236
|
function defaultPidAlive(pid: number): boolean {
|
|
@@ -187,6 +257,12 @@ export interface AdapterConfigView {
|
|
|
187
257
|
botTokenMasked: string;
|
|
188
258
|
channel: string | undefined;
|
|
189
259
|
configured: boolean;
|
|
260
|
+
quarantined: boolean;
|
|
261
|
+
desiredEnabled: boolean;
|
|
262
|
+
desiredSource: ProviderResolution["desiredSource"];
|
|
263
|
+
effectiveEnabled: boolean;
|
|
264
|
+
issues: ProviderResolution["issues"];
|
|
265
|
+
runtime?: NotificationRuntime;
|
|
190
266
|
}
|
|
191
267
|
|
|
192
268
|
export interface NotificationStatusReport {
|
|
@@ -194,13 +270,30 @@ export interface NotificationStatusReport {
|
|
|
194
270
|
redact: boolean;
|
|
195
271
|
verbosity: "lean" | "verbose";
|
|
196
272
|
globallyConfigured: boolean;
|
|
273
|
+
anyProviderComplete: boolean;
|
|
274
|
+
anyProviderEffective: boolean;
|
|
197
275
|
telegram: AdapterConfigView & { tokenFingerprint: string | undefined };
|
|
198
276
|
discord: AdapterConfigView;
|
|
199
277
|
slack: AdapterConfigView;
|
|
200
278
|
}
|
|
201
279
|
|
|
202
|
-
function
|
|
203
|
-
|
|
280
|
+
function adapterView(
|
|
281
|
+
cfg: NotificationConfig,
|
|
282
|
+
provider: NotificationProvider,
|
|
283
|
+
token: string | undefined,
|
|
284
|
+
channel: string | undefined,
|
|
285
|
+
): AdapterConfigView {
|
|
286
|
+
const resolution = resolveNotificationProvider(cfg, provider);
|
|
287
|
+
return {
|
|
288
|
+
botTokenMasked: maskToken(token),
|
|
289
|
+
channel,
|
|
290
|
+
configured: resolution.configured,
|
|
291
|
+
quarantined: resolution.quarantined,
|
|
292
|
+
desiredEnabled: resolution.desiredEnabled,
|
|
293
|
+
desiredSource: resolution.desiredSource,
|
|
294
|
+
effectiveEnabled: resolution.effectiveEnabled,
|
|
295
|
+
issues: resolution.issues,
|
|
296
|
+
};
|
|
204
297
|
}
|
|
205
298
|
|
|
206
299
|
/** Build a secret-safe structured status snapshot from settings. */
|
|
@@ -210,43 +303,40 @@ export function buildNotificationStatusReport(settings: Settings): NotificationS
|
|
|
210
303
|
enabled: cfg.enabled,
|
|
211
304
|
redact: cfg.redact,
|
|
212
305
|
verbosity: cfg.verbosity,
|
|
213
|
-
globallyConfigured:
|
|
306
|
+
globallyConfigured: cfg.enabled && hasAnyCompleteProvider(cfg),
|
|
307
|
+
anyProviderComplete: hasAnyCompleteProvider(cfg),
|
|
308
|
+
anyProviderEffective: hasAnyEffectivelyEnabledProvider(cfg),
|
|
214
309
|
telegram: {
|
|
215
|
-
|
|
216
|
-
channel: cfg.chatId,
|
|
217
|
-
configured: isTelegramConfigured(cfg),
|
|
310
|
+
...adapterView(cfg, "telegram", cfg.botToken, cfg.chatId),
|
|
218
311
|
tokenFingerprint: cfg.botToken?.trim() ? tokenFingerprint(cfg.botToken) : undefined,
|
|
219
312
|
},
|
|
220
|
-
discord:
|
|
221
|
-
|
|
222
|
-
channel: cfg.discord.parentChannelId,
|
|
223
|
-
configured: isDiscordConfigured(cfg),
|
|
224
|
-
},
|
|
225
|
-
slack: {
|
|
226
|
-
botTokenMasked: maskToken(cfg.slack.botToken),
|
|
227
|
-
channel: cfg.slack.channelId,
|
|
228
|
-
configured: adapterConfigured(cfg.slack.botToken, cfg.slack.channelId),
|
|
229
|
-
},
|
|
313
|
+
discord: adapterView(cfg, "discord", cfg.discord.botToken, cfg.discord.parentChannelId),
|
|
314
|
+
slack: adapterView(cfg, "slack", cfg.slack.botToken, cfg.slack.channelId),
|
|
230
315
|
};
|
|
231
316
|
}
|
|
232
317
|
|
|
233
318
|
/** Render a status report as human-readable lines (no secrets). */
|
|
234
319
|
export function formatNotificationStatusReport(report: NotificationStatusReport): string {
|
|
235
|
-
const yesNo = (
|
|
320
|
+
const yesNo = (value: boolean): string => (value ? "yes" : "no");
|
|
321
|
+
const provider = (name: NotificationProvider, view: AdapterConfigView): string[] => [
|
|
322
|
+
` ${name}.configured: ${yesNo(view.configured)}`,
|
|
323
|
+
` ${name}.needsRepair: ${yesNo(view.quarantined)}`,
|
|
324
|
+
` ${name}.desired: ${view.desiredEnabled ? "on" : "off"} (${view.desiredSource})`,
|
|
325
|
+
` ${name}.effective: ${yesNo(view.effectiveEnabled)}`,
|
|
326
|
+
` ${name}.botToken: ${view.botTokenMasked}`,
|
|
327
|
+
` ${name}.destination: ${view.channel ?? "(unset)"}`,
|
|
328
|
+
];
|
|
236
329
|
return [
|
|
237
330
|
"Notifications",
|
|
238
331
|
` enabled: ${report.enabled}`,
|
|
239
|
-
`
|
|
332
|
+
` any provider effective: ${yesNo(report.anyProviderEffective)}`,
|
|
333
|
+
` any provider complete: ${yesNo(report.anyProviderComplete)}`,
|
|
240
334
|
` redact: ${report.redact}`,
|
|
241
335
|
` verbosity: ${report.verbosity}`,
|
|
242
|
-
|
|
243
|
-
` telegram.chatId: ${report.telegram.channel ?? "(unset)"}`,
|
|
336
|
+
...provider("telegram", report.telegram),
|
|
244
337
|
` telegram.fingerprint: ${report.telegram.tokenFingerprint ?? "(unset)"}`,
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
` discord.parentChannelId: ${report.discord.channel ?? "(unset)"}`,
|
|
248
|
-
` slack.botToken: ${report.slack.botTokenMasked}`,
|
|
249
|
-
` slack.channelId: ${report.slack.channel ?? "(unset)"}`,
|
|
338
|
+
...provider("discord", report.discord),
|
|
339
|
+
...provider("slack", report.slack),
|
|
250
340
|
].join("\n");
|
|
251
341
|
}
|
|
252
342
|
|
|
@@ -508,6 +598,8 @@ export interface EndpointHealth {
|
|
|
508
598
|
export interface NotificationHealthReport {
|
|
509
599
|
overall: HealthLevel;
|
|
510
600
|
configured: boolean;
|
|
601
|
+
provider?: NotificationProvider;
|
|
602
|
+
resolution?: ProviderResolution;
|
|
511
603
|
checks: HealthCheck[];
|
|
512
604
|
daemon: DaemonHealth;
|
|
513
605
|
endpoints: EndpointHealth;
|
|
@@ -518,20 +610,25 @@ export interface HealthOptions {
|
|
|
518
610
|
settings: Settings;
|
|
519
611
|
stateRoot?: string;
|
|
520
612
|
deps?: NotificationServiceDeps;
|
|
521
|
-
|
|
613
|
+
provider?: NotificationProvider;
|
|
614
|
+
/** When true, use the selected provider's REST-only diagnostic path. */
|
|
522
615
|
probe?: boolean;
|
|
616
|
+
signal?: AbortSignal;
|
|
523
617
|
}
|
|
524
618
|
|
|
525
619
|
async function probeTelegramReachability(
|
|
526
620
|
fetchImpl: typeof fetch,
|
|
527
621
|
apiBase: string,
|
|
528
622
|
token: string,
|
|
623
|
+
signal?: AbortSignal,
|
|
529
624
|
): Promise<{ ok: boolean; detail: string }> {
|
|
625
|
+
if (signal?.aborted) return { ok: false, detail: "Telegram probe cancelled." };
|
|
530
626
|
try {
|
|
531
627
|
const response = await fetchImpl(`${apiBase.replace(/\/$/, "")}/bot${token}/getMe`, {
|
|
532
628
|
method: "POST",
|
|
533
629
|
headers: { "content-type": "application/json" },
|
|
534
630
|
body: "{}",
|
|
631
|
+
signal,
|
|
535
632
|
});
|
|
536
633
|
const payload = (await response.json().catch(() => undefined)) as
|
|
537
634
|
| { ok?: boolean; description?: string; result?: { username?: string } }
|
|
@@ -544,8 +641,78 @@ async function probeTelegramReachability(
|
|
|
544
641
|
ok: false,
|
|
545
642
|
detail: sanitizeDiagnostic(payload?.description ?? `Telegram getMe failed (HTTP ${response.status})`, token),
|
|
546
643
|
};
|
|
547
|
-
} catch (
|
|
548
|
-
return { ok: false, detail:
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (signal?.aborted) return { ok: false, detail: "Telegram probe cancelled." };
|
|
646
|
+
return { ok: false, detail: sanitizeDiagnostic(error instanceof Error ? error.message : "network error", token) };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function effectiveProviders(cfg: NotificationConfig): NotificationProvider[] {
|
|
651
|
+
return (["telegram", "discord", "slack"] as const).filter(provider => isProviderEffectivelyEnabled(cfg, provider));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function selectNotificationProvider(
|
|
655
|
+
cfg: NotificationConfig,
|
|
656
|
+
requested: NotificationProvider | undefined,
|
|
657
|
+
): NotificationProvider | undefined {
|
|
658
|
+
if (requested) return requested;
|
|
659
|
+
const effective = effectiveProviders(cfg);
|
|
660
|
+
return effective.length === 1 ? effective[0] : undefined;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function probeSelectedProvider(
|
|
664
|
+
cfg: NotificationConfig,
|
|
665
|
+
provider: NotificationProvider,
|
|
666
|
+
deps: NotificationServiceDeps,
|
|
667
|
+
signal?: AbortSignal,
|
|
668
|
+
): Promise<{ ok: boolean; detail: string }> {
|
|
669
|
+
try {
|
|
670
|
+
let result: { ok: boolean; detail: string };
|
|
671
|
+
if (provider === "telegram" && isTelegramComplete(cfg)) {
|
|
672
|
+
result = await probeTelegramReachability(
|
|
673
|
+
deps.fetchImpl ?? globalThis.fetch,
|
|
674
|
+
deps.apiBase ?? DEFAULT_API_BASE,
|
|
675
|
+
cfg.botToken,
|
|
676
|
+
signal,
|
|
677
|
+
);
|
|
678
|
+
} else if (provider === "discord" && isDiscordComplete(cfg)) {
|
|
679
|
+
const adapter =
|
|
680
|
+
deps.createDiscordDiagnostic?.({
|
|
681
|
+
applicationId: cfg.discord.applicationId,
|
|
682
|
+
botToken: cfg.discord.botToken,
|
|
683
|
+
}) ??
|
|
684
|
+
new DiscordLiveProvider({
|
|
685
|
+
applicationId: cfg.discord.applicationId,
|
|
686
|
+
botToken: cfg.discord.botToken,
|
|
687
|
+
fetchImpl: deps.fetchImpl,
|
|
688
|
+
});
|
|
689
|
+
result = await adapter.probeConfiguration(signal);
|
|
690
|
+
} else if (provider === "slack" && isSlackComplete(cfg)) {
|
|
691
|
+
const adapter =
|
|
692
|
+
deps.createSlackDiagnostic?.({ appToken: cfg.slack.appToken, botToken: cfg.slack.botToken }) ??
|
|
693
|
+
new SlackLiveProvider({
|
|
694
|
+
appToken: cfg.slack.appToken,
|
|
695
|
+
botToken: cfg.slack.botToken,
|
|
696
|
+
fetch: deps.fetchImpl,
|
|
697
|
+
});
|
|
698
|
+
const probe = await adapter.probeConfiguration(signal);
|
|
699
|
+
result =
|
|
700
|
+
probe.ok && (!probe.teamId || probe.teamId !== cfg.slack.workspaceId)
|
|
701
|
+
? { ok: false, detail: "Slack workspace identity does not match the configured workspace ID." }
|
|
702
|
+
: probe;
|
|
703
|
+
} else {
|
|
704
|
+
result = { ok: false, detail: `${provider} configuration is unavailable.` };
|
|
705
|
+
}
|
|
706
|
+
return { ...result, detail: sanitizeProviderDiagnostic(result.detail, cfg, provider) };
|
|
707
|
+
} catch (error) {
|
|
708
|
+
return {
|
|
709
|
+
ok: false,
|
|
710
|
+
detail: sanitizeProviderDiagnostic(
|
|
711
|
+
error instanceof Error ? error.message : `${provider} diagnostic failed.`,
|
|
712
|
+
cfg,
|
|
713
|
+
provider,
|
|
714
|
+
),
|
|
715
|
+
};
|
|
549
716
|
}
|
|
550
717
|
}
|
|
551
718
|
|
|
@@ -563,11 +730,25 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
563
730
|
const stateRoot = opts.stateRoot ?? defaultStateRoot();
|
|
564
731
|
|
|
565
732
|
const cfg: NotificationConfig = getNotificationConfig(opts.settings);
|
|
566
|
-
const
|
|
567
|
-
const
|
|
733
|
+
const provider = selectNotificationProvider(cfg, opts.provider);
|
|
734
|
+
const resolution = provider ? resolveNotificationProvider(cfg, provider) : undefined;
|
|
735
|
+
const configured = resolution?.configured ?? hasAnyCompleteProvider(cfg);
|
|
736
|
+
const telegramConfigured = isTelegramComplete(cfg);
|
|
568
737
|
const checks: HealthCheck[] = [];
|
|
569
738
|
|
|
570
|
-
if (
|
|
739
|
+
if (provider && resolution?.quarantined) {
|
|
740
|
+
checks.push({ name: "config", level: "error", detail: `${provider} configuration needs repair` });
|
|
741
|
+
} else if (provider && !resolution?.configured) {
|
|
742
|
+
checks.push({ name: "config", level: "warn", detail: `${provider} is not fully configured` });
|
|
743
|
+
} else if (provider && !resolution?.desiredEnabled) {
|
|
744
|
+
checks.push({ name: "intent", level: "warn", detail: `${provider} desired intent is off` });
|
|
745
|
+
} else if (provider && !resolution?.effectiveEnabled) {
|
|
746
|
+
checks.push({
|
|
747
|
+
name: "config",
|
|
748
|
+
level: "warn",
|
|
749
|
+
detail: `${provider} is not effective while the global master is off`,
|
|
750
|
+
});
|
|
751
|
+
} else if (!cfg.enabled) {
|
|
571
752
|
checks.push({
|
|
572
753
|
name: "config",
|
|
573
754
|
level: "warn",
|
|
@@ -575,8 +756,24 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
575
756
|
});
|
|
576
757
|
} else if (!configured) {
|
|
577
758
|
checks.push({ name: "config", level: "warn", detail: "no notification adapter is fully configured" });
|
|
759
|
+
} else if (!provider && effectiveProviders(cfg).length === 0) {
|
|
760
|
+
checks.push({
|
|
761
|
+
name: "config",
|
|
762
|
+
level: "warn",
|
|
763
|
+
detail: "no configured provider currently has desired intent enabled",
|
|
764
|
+
});
|
|
765
|
+
} else if (!provider && effectiveProviders(cfg).length > 1) {
|
|
766
|
+
checks.push({
|
|
767
|
+
name: "selection",
|
|
768
|
+
level: "warn",
|
|
769
|
+
detail: "select a provider because multiple providers are effective",
|
|
770
|
+
});
|
|
578
771
|
} else {
|
|
579
|
-
checks.push({
|
|
772
|
+
checks.push({
|
|
773
|
+
name: "config",
|
|
774
|
+
level: "ok",
|
|
775
|
+
detail: provider ? `${provider} is effective` : "enabled with one configured adapter",
|
|
776
|
+
});
|
|
580
777
|
}
|
|
581
778
|
|
|
582
779
|
// Daemon ownership state (offline; corrupt or unreadable state degrades to a
|
|
@@ -612,26 +809,28 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
612
809
|
currentGeneration: DAEMON_GENERATION,
|
|
613
810
|
generationRelation: daemonGenerationRelation(state),
|
|
614
811
|
};
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
812
|
+
if (!provider || provider === "telegram") {
|
|
813
|
+
if (daemonStateUnreadable) {
|
|
814
|
+
checks.push({ name: "daemon", level: "warn", detail: "daemon ownership record is corrupt or unreadable" });
|
|
815
|
+
} else if (!state) {
|
|
816
|
+
checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
|
|
817
|
+
} else if (!daemon.alive) {
|
|
818
|
+
checks.push({
|
|
819
|
+
name: "daemon",
|
|
820
|
+
level: "warn",
|
|
821
|
+
detail: `daemon owner pid ${daemon.pid} is not alive; run recovery to clear the stale lock`,
|
|
822
|
+
});
|
|
823
|
+
} else if (!daemon.heartbeatFresh) {
|
|
824
|
+
checks.push({ name: "daemon", level: "warn", detail: `daemon pid ${daemon.pid} heartbeat is stale` });
|
|
825
|
+
} else if (telegramConfigured && !daemon.identityMatches) {
|
|
826
|
+
checks.push({
|
|
827
|
+
name: "daemon",
|
|
828
|
+
level: "warn",
|
|
829
|
+
detail: "a live daemon owns a different bot token or chat id",
|
|
830
|
+
});
|
|
831
|
+
} else {
|
|
832
|
+
checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
|
|
833
|
+
}
|
|
635
834
|
}
|
|
636
835
|
|
|
637
836
|
// Per-session endpoint discovery files.
|
|
@@ -667,54 +866,68 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
667
866
|
unknown: unknownEndpoints,
|
|
668
867
|
unreadable,
|
|
669
868
|
};
|
|
670
|
-
if (
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
869
|
+
if (!provider || provider === "telegram") {
|
|
870
|
+
if (dead > 0 || unreadable > 0) {
|
|
871
|
+
checks.push({
|
|
872
|
+
name: "endpoints",
|
|
873
|
+
level: "warn",
|
|
874
|
+
detail: `${dead} dead / ${unreadable} unreadable of ${endpoints.total} endpoint file(s); run recovery`,
|
|
875
|
+
});
|
|
876
|
+
} else {
|
|
877
|
+
checks.push({
|
|
878
|
+
name: "endpoints",
|
|
879
|
+
level: "ok",
|
|
880
|
+
detail: `${live} live, ${unknownEndpoints} unverified endpoint file(s)`,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
if (
|
|
884
|
+
telegramConfigured &&
|
|
885
|
+
daemon.present &&
|
|
886
|
+
daemon.alive &&
|
|
887
|
+
daemon.heartbeatFresh &&
|
|
888
|
+
daemon.identityMatches &&
|
|
889
|
+
!daemon.stopped &&
|
|
890
|
+
endpoints.total === 0
|
|
891
|
+
) {
|
|
892
|
+
checks.push({
|
|
893
|
+
name: "local_endpoint",
|
|
894
|
+
level: "warn",
|
|
895
|
+
detail:
|
|
896
|
+
"No local notification endpoint for this working directory. In this GJC terminal run /notify on; if it does not report notifications enabled, start a new local GJC session. Do not re-pair Telegram.",
|
|
897
|
+
});
|
|
898
|
+
}
|
|
698
899
|
}
|
|
699
900
|
|
|
700
|
-
// Optional
|
|
901
|
+
// Optional selected REST-only reachability probe.
|
|
701
902
|
let reachability = { probed: false, ok: false, detail: "not probed" };
|
|
702
|
-
if (opts.probe
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
903
|
+
if (opts.probe) {
|
|
904
|
+
if (!provider) {
|
|
905
|
+
reachability = { probed: false, ok: false, detail: "provider selection required" };
|
|
906
|
+
checks.push({ name: "reachability", level: "error", detail: "provider selection required" });
|
|
907
|
+
} else if (!resolution?.configured || resolution.quarantined) {
|
|
908
|
+
reachability = { probed: false, ok: false, detail: `${provider} is unavailable` };
|
|
909
|
+
checks.push({ name: "reachability", level: "error", detail: `${provider} is unavailable` });
|
|
910
|
+
} else {
|
|
911
|
+
const result = await probeSelectedProvider(cfg, provider, deps, opts.signal);
|
|
912
|
+
reachability = { probed: true, ...result };
|
|
913
|
+
checks.push({
|
|
914
|
+
name: "reachability",
|
|
915
|
+
level: result.ok ? "ok" : "error",
|
|
916
|
+
detail: `${provider}: ${result.detail}`,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
714
919
|
}
|
|
715
920
|
|
|
716
921
|
const overall = checks.reduce<HealthLevel>((acc, check) => worst(acc, check.level), "ok");
|
|
717
|
-
return {
|
|
922
|
+
return {
|
|
923
|
+
overall,
|
|
924
|
+
configured,
|
|
925
|
+
...(provider ? { provider, resolution } : {}),
|
|
926
|
+
checks,
|
|
927
|
+
daemon,
|
|
928
|
+
endpoints,
|
|
929
|
+
reachability,
|
|
930
|
+
};
|
|
718
931
|
}
|
|
719
932
|
|
|
720
933
|
/** Render a health report as human-readable lines (no secrets). */
|
|
@@ -731,66 +944,198 @@ export function formatNotificationHealthReport(report: NotificationHealthReport)
|
|
|
731
944
|
|
|
732
945
|
export interface NotificationTestResult {
|
|
733
946
|
ok: boolean;
|
|
734
|
-
adapter
|
|
735
|
-
|
|
947
|
+
adapter?: NotificationProvider;
|
|
948
|
+
destination?: string;
|
|
736
949
|
detail: string;
|
|
950
|
+
uncertain?: boolean;
|
|
737
951
|
}
|
|
738
952
|
|
|
739
953
|
export interface TestOptions {
|
|
740
954
|
settings: Settings;
|
|
741
955
|
deps?: NotificationServiceDeps;
|
|
956
|
+
provider?: NotificationProvider;
|
|
742
957
|
text?: string;
|
|
958
|
+
signal?: AbortSignal;
|
|
743
959
|
}
|
|
744
960
|
|
|
745
|
-
|
|
961
|
+
async function selectedProviderRuntimeReady(
|
|
962
|
+
provider: NotificationProvider,
|
|
963
|
+
deps: NotificationServiceDeps,
|
|
964
|
+
): Promise<boolean> {
|
|
965
|
+
if (!deps.providerRuntimeStatus) return false;
|
|
966
|
+
const status = await deps.providerRuntimeStatus(provider);
|
|
967
|
+
return status === "ready" || status === "attached";
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Send a one-off test through exactly one durable, effective provider. */
|
|
746
971
|
export async function sendNotificationTest(opts: TestOptions): Promise<NotificationTestResult> {
|
|
747
972
|
const deps = opts.deps ?? {};
|
|
748
973
|
const cfg = getNotificationConfig(opts.settings);
|
|
749
|
-
|
|
974
|
+
const provider = selectNotificationProvider(cfg, opts.provider);
|
|
975
|
+
if (!provider) {
|
|
750
976
|
return {
|
|
751
977
|
ok: false,
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
978
|
+
detail:
|
|
979
|
+
effectiveProviders(cfg).length === 0
|
|
980
|
+
? "No notification provider is effective. Configure and enable one provider first."
|
|
981
|
+
: "Multiple notification providers are effective; select one with --provider.",
|
|
755
982
|
};
|
|
756
983
|
}
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
const text = opts.text ?? "GJC notifications test message. If you can read this, delivery works.";
|
|
760
|
-
try {
|
|
761
|
-
const response = await fetchImpl(`${apiBase}/bot${cfg.botToken}/sendMessage`, {
|
|
762
|
-
method: "POST",
|
|
763
|
-
headers: { "content-type": "application/json" },
|
|
764
|
-
body: JSON.stringify({ chat_id: cfg.chatId, text }),
|
|
765
|
-
});
|
|
766
|
-
const payload = (await response.json().catch(() => undefined)) as
|
|
767
|
-
| { ok?: boolean; description?: string }
|
|
768
|
-
| undefined;
|
|
769
|
-
if (response.ok && payload?.ok) {
|
|
770
|
-
return { ok: true, adapter: "telegram", chatId: cfg.chatId, detail: `delivered to chat ${cfg.chatId}` };
|
|
771
|
-
}
|
|
984
|
+
const resolution = resolveNotificationProvider(cfg, provider);
|
|
985
|
+
if (!resolution.effectiveEnabled) {
|
|
772
986
|
return {
|
|
773
987
|
ok: false,
|
|
774
|
-
adapter:
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
cfg.botToken,
|
|
779
|
-
),
|
|
988
|
+
adapter: provider,
|
|
989
|
+
detail: resolution.quarantined
|
|
990
|
+
? `${provider} configuration needs repair.`
|
|
991
|
+
: `${provider} is unavailable because configuration, desired intent, or the global master is off.`,
|
|
780
992
|
};
|
|
781
|
-
}
|
|
993
|
+
}
|
|
994
|
+
try {
|
|
995
|
+
if (!(await selectedProviderRuntimeReady(provider, deps))) {
|
|
996
|
+
return { ok: false, adapter: provider, detail: `${provider} runtime is not ready or attached.` };
|
|
997
|
+
}
|
|
998
|
+
} catch (error) {
|
|
782
999
|
return {
|
|
783
1000
|
ok: false,
|
|
784
|
-
adapter:
|
|
785
|
-
|
|
786
|
-
|
|
1001
|
+
adapter: provider,
|
|
1002
|
+
detail: sanitizeProviderDiagnostic(
|
|
1003
|
+
error instanceof Error ? error.message : `${provider} runtime readiness check failed.`,
|
|
1004
|
+
cfg,
|
|
1005
|
+
provider,
|
|
1006
|
+
),
|
|
787
1007
|
};
|
|
788
1008
|
}
|
|
1009
|
+
if (opts.signal?.aborted)
|
|
1010
|
+
return { ok: false, adapter: provider, detail: `${provider} notification test cancelled.` };
|
|
1011
|
+
const text = opts.text ?? "GJC notifications test message. If you can read this, delivery works.";
|
|
1012
|
+
if (provider === "telegram" && isTelegramComplete(cfg)) {
|
|
1013
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
1014
|
+
const apiBase = (deps.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
1015
|
+
try {
|
|
1016
|
+
const response = await fetchImpl(`${apiBase}/bot${cfg.botToken}/sendMessage`, {
|
|
1017
|
+
method: "POST",
|
|
1018
|
+
headers: { "content-type": "application/json" },
|
|
1019
|
+
body: JSON.stringify({ chat_id: cfg.chatId, text }),
|
|
1020
|
+
signal: opts.signal,
|
|
1021
|
+
});
|
|
1022
|
+
const payload = (await response.json().catch(() => undefined)) as
|
|
1023
|
+
| { ok?: boolean; description?: string; result?: { message_id?: unknown } }
|
|
1024
|
+
| undefined;
|
|
1025
|
+
const messageId = payload?.result?.message_id;
|
|
1026
|
+
if (response.ok && payload?.ok === true && typeof messageId === "number" && Number.isSafeInteger(messageId)) {
|
|
1027
|
+
return {
|
|
1028
|
+
ok: true,
|
|
1029
|
+
adapter: "telegram",
|
|
1030
|
+
destination: cfg.chatId,
|
|
1031
|
+
detail: `delivered to chat ${cfg.chatId}`,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const uncertain = response.ok && payload?.ok !== false;
|
|
1035
|
+
return {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
adapter: "telegram",
|
|
1038
|
+
destination: cfg.chatId,
|
|
1039
|
+
...(uncertain ? { uncertain: true } : {}),
|
|
1040
|
+
detail: sanitizeDiagnostic(
|
|
1041
|
+
uncertain
|
|
1042
|
+
? "Telegram may have accepted the message but returned no usable message receipt."
|
|
1043
|
+
: (payload?.description ?? `Telegram sendMessage failed (HTTP ${response.status})`),
|
|
1044
|
+
cfg.botToken,
|
|
1045
|
+
),
|
|
1046
|
+
};
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
return {
|
|
1049
|
+
ok: false,
|
|
1050
|
+
adapter: "telegram",
|
|
1051
|
+
destination: cfg.chatId,
|
|
1052
|
+
uncertain: true,
|
|
1053
|
+
detail: opts.signal?.aborted
|
|
1054
|
+
? "Telegram notification delivery is uncertain because cancellation raced with dispatch."
|
|
1055
|
+
: sanitizeDiagnostic(error instanceof Error ? error.message : "network error", cfg.botToken),
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (provider === "discord" && isDiscordComplete(cfg)) {
|
|
1060
|
+
try {
|
|
1061
|
+
const adapter =
|
|
1062
|
+
deps.createDiscordDiagnostic?.({
|
|
1063
|
+
applicationId: cfg.discord.applicationId,
|
|
1064
|
+
botToken: cfg.discord.botToken,
|
|
1065
|
+
}) ??
|
|
1066
|
+
new DiscordLiveProvider({
|
|
1067
|
+
applicationId: cfg.discord.applicationId,
|
|
1068
|
+
botToken: cfg.discord.botToken,
|
|
1069
|
+
fetchImpl: deps.fetchImpl,
|
|
1070
|
+
});
|
|
1071
|
+
const result = await adapter.sendOneShotTest({
|
|
1072
|
+
channelId: cfg.discord.parentChannelId,
|
|
1073
|
+
message: text,
|
|
1074
|
+
signal: opts.signal,
|
|
1075
|
+
});
|
|
1076
|
+
return {
|
|
1077
|
+
ok: result.ok,
|
|
1078
|
+
adapter: "discord",
|
|
1079
|
+
destination: cfg.discord.parentChannelId,
|
|
1080
|
+
detail: sanitizeProviderDiagnostic(result.detail, cfg, "discord"),
|
|
1081
|
+
...(result.uncertain ? { uncertain: true } : {}),
|
|
1082
|
+
};
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
adapter: "discord",
|
|
1087
|
+
destination: cfg.discord.parentChannelId,
|
|
1088
|
+
uncertain: true,
|
|
1089
|
+
detail: sanitizeProviderDiagnostic(
|
|
1090
|
+
error instanceof Error ? error.message : "Discord notification delivery failed.",
|
|
1091
|
+
cfg,
|
|
1092
|
+
"discord",
|
|
1093
|
+
),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (provider === "slack" && isSlackComplete(cfg)) {
|
|
1098
|
+
try {
|
|
1099
|
+
const adapter =
|
|
1100
|
+
deps.createSlackDiagnostic?.({ appToken: cfg.slack.appToken, botToken: cfg.slack.botToken }) ??
|
|
1101
|
+
new SlackLiveProvider({
|
|
1102
|
+
appToken: cfg.slack.appToken,
|
|
1103
|
+
botToken: cfg.slack.botToken,
|
|
1104
|
+
fetch: deps.fetchImpl,
|
|
1105
|
+
});
|
|
1106
|
+
const result = await adapter.sendOneShotTest({
|
|
1107
|
+
channel: cfg.slack.channelId,
|
|
1108
|
+
message: text,
|
|
1109
|
+
idempotencyKey: crypto.randomUUID(),
|
|
1110
|
+
signal: opts.signal,
|
|
1111
|
+
});
|
|
1112
|
+
return {
|
|
1113
|
+
ok: result.ok,
|
|
1114
|
+
adapter: "slack",
|
|
1115
|
+
destination: cfg.slack.channelId,
|
|
1116
|
+
detail: sanitizeProviderDiagnostic(result.detail, cfg, "slack"),
|
|
1117
|
+
...(result.uncertain ? { uncertain: true } : {}),
|
|
1118
|
+
};
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
return {
|
|
1121
|
+
ok: false,
|
|
1122
|
+
adapter: "slack",
|
|
1123
|
+
destination: cfg.slack.channelId,
|
|
1124
|
+
uncertain: true,
|
|
1125
|
+
detail: sanitizeProviderDiagnostic(
|
|
1126
|
+
error instanceof Error ? error.message : "Slack notification delivery failed.",
|
|
1127
|
+
cfg,
|
|
1128
|
+
"slack",
|
|
1129
|
+
),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return { ok: false, adapter: provider, detail: `${provider} configuration is unavailable.` };
|
|
789
1134
|
}
|
|
790
1135
|
|
|
791
1136
|
/** Render a test result as a single human-readable line (no secrets). */
|
|
792
1137
|
export function formatNotificationTestResult(result: NotificationTestResult): string {
|
|
793
|
-
return `Notification test (${result.adapter}): ${result.ok ? "OK" : "FAILED"} — ${result.detail}`;
|
|
1138
|
+
return `Notification test (${result.adapter ?? "unselected"}): ${result.ok ? "OK" : result.uncertain ? "UNCERTAIN" : "FAILED"} — ${result.detail}`;
|
|
794
1139
|
}
|
|
795
1140
|
|
|
796
1141
|
// --- recovery -----------------------------------------------------------
|
|
@@ -825,6 +1170,9 @@ export interface NotificationRecoveryReport {
|
|
|
825
1170
|
action: DaemonRecoveryAction;
|
|
826
1171
|
detail: string;
|
|
827
1172
|
ownerId: string | undefined;
|
|
1173
|
+
blockingReason?: string;
|
|
1174
|
+
markerAgeMs?: number;
|
|
1175
|
+
forceCommand?: string;
|
|
828
1176
|
pid: number | undefined;
|
|
829
1177
|
};
|
|
830
1178
|
}
|
|
@@ -833,6 +1181,7 @@ export interface RecoveryOptions {
|
|
|
833
1181
|
settings: Settings;
|
|
834
1182
|
stateRoot?: string;
|
|
835
1183
|
deps?: NotificationServiceDeps;
|
|
1184
|
+
forceDaemonLock?: boolean;
|
|
836
1185
|
}
|
|
837
1186
|
|
|
838
1187
|
export interface DaemonTransitionLock {
|
|
@@ -866,6 +1215,7 @@ type TransitionMarkerFs = {
|
|
|
866
1215
|
writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise<void>;
|
|
867
1216
|
readEndpointFile?(file: string): Promise<NotificationEndpointFile>;
|
|
868
1217
|
exactUnlink?(file: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
|
|
1218
|
+
stat?(file: string): Promise<{ mtimeMs: number }>;
|
|
869
1219
|
};
|
|
870
1220
|
|
|
871
1221
|
async function readTransitionMarker(
|
|
@@ -1017,6 +1367,36 @@ function defaultTransitionPidIncarnation(pid: number): string | undefined {
|
|
|
1017
1367
|
return processIncarnation(pid);
|
|
1018
1368
|
}
|
|
1019
1369
|
|
|
1370
|
+
const TRANSITION_MARKER_GRACE_MS = 3 * HEARTBEAT_TTL_MS;
|
|
1371
|
+
|
|
1372
|
+
async function detachStaleTransitionMarker(input: {
|
|
1373
|
+
fs: TransitionMarkerFs;
|
|
1374
|
+
path: string;
|
|
1375
|
+
now: number;
|
|
1376
|
+
pidAlive: (pid: number) => boolean;
|
|
1377
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
1378
|
+
}): Promise<boolean> {
|
|
1379
|
+
const snapshot = await readTransitionMarker(input.fs, input.path);
|
|
1380
|
+
if (!snapshot) return false;
|
|
1381
|
+
let marker: unknown;
|
|
1382
|
+
try {
|
|
1383
|
+
marker = JSON.parse(snapshot.raw);
|
|
1384
|
+
} catch {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
if (!isDaemonTransitionLock(marker)) return false;
|
|
1388
|
+
const currentIncarnation = input.pidIncarnation(marker.pid);
|
|
1389
|
+
if (
|
|
1390
|
+
input.pidAlive(marker.pid) ||
|
|
1391
|
+
!isProcessIncarnation(marker.incarnation) ||
|
|
1392
|
+
(isProcessIncarnation(currentIncarnation) && currentIncarnation === marker.incarnation)
|
|
1393
|
+
)
|
|
1394
|
+
return false;
|
|
1395
|
+
const ageMs = input.now - marker.createdAt;
|
|
1396
|
+
if (!Number.isFinite(ageMs) || ageMs <= TRANSITION_MARKER_GRACE_MS) return false;
|
|
1397
|
+
return await detachTransitionMarker(input.fs, input.path, snapshot);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1020
1400
|
/**
|
|
1021
1401
|
* Owner-bound removal of a dead daemon's lock. Closes the classic
|
|
1022
1402
|
* check-then-unlink TOCTOU: a naive `unlink(lock)` after observing a dead owner
|
|
@@ -1031,20 +1411,36 @@ async function removeDeadOwnerLock(
|
|
|
1031
1411
|
paths: DaemonPaths,
|
|
1032
1412
|
pidAlive: (pid: number) => boolean,
|
|
1033
1413
|
expected: NormalizedDaemonState,
|
|
1414
|
+
now: () => number,
|
|
1415
|
+
pidIncarnation: (pid: number) => string | undefined,
|
|
1034
1416
|
): Promise<"cleared" | "contended" | "superseded" | "now-alive" | "unlink-failed"> {
|
|
1035
|
-
|
|
1417
|
+
let transition = await acquireDaemonTransitionLock({
|
|
1036
1418
|
fs,
|
|
1037
1419
|
path: paths.steal,
|
|
1038
1420
|
pid: process.pid,
|
|
1039
1421
|
pidAlive: defaultTransitionPidAlive,
|
|
1040
1422
|
pidIncarnation: defaultTransitionPidIncarnation,
|
|
1041
1423
|
});
|
|
1424
|
+
if (!transition) {
|
|
1425
|
+
await detachStaleTransitionMarker({
|
|
1426
|
+
fs,
|
|
1427
|
+
path: paths.steal,
|
|
1428
|
+
now: now(),
|
|
1429
|
+
pidAlive,
|
|
1430
|
+
pidIncarnation,
|
|
1431
|
+
});
|
|
1432
|
+
transition = await acquireDaemonTransitionLock({
|
|
1433
|
+
fs,
|
|
1434
|
+
path: paths.steal,
|
|
1435
|
+
pid: process.pid,
|
|
1436
|
+
pidAlive: defaultTransitionPidAlive,
|
|
1437
|
+
pidIncarnation: defaultTransitionPidIncarnation,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1042
1440
|
if (!transition) return "contended";
|
|
1043
1441
|
try {
|
|
1044
1442
|
const current = await readDaemonStateFile(fs, paths.state);
|
|
1045
|
-
if (!current || current.ownerId !== expected.ownerId || current.pid !== expected.pid)
|
|
1046
|
-
return "superseded";
|
|
1047
|
-
}
|
|
1443
|
+
if (!current || current.ownerId !== expected.ownerId || current.pid !== expected.pid) return "superseded";
|
|
1048
1444
|
if (pidAlive(current.pid)) return "now-alive";
|
|
1049
1445
|
if (!(await daemonTransitionLockIsHeld({ fs, path: paths.steal, lock: transition }))) return "contended";
|
|
1050
1446
|
try {
|
|
@@ -1072,6 +1468,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
1072
1468
|
const deps = opts.deps ?? {};
|
|
1073
1469
|
const fs = deps.fs ?? nodeServiceFs;
|
|
1074
1470
|
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
1471
|
+
const now = deps.now ?? Date.now;
|
|
1075
1472
|
const stateRoot = opts.stateRoot ?? defaultStateRoot();
|
|
1076
1473
|
|
|
1077
1474
|
const dir = endpointDir(stateRoot);
|
|
@@ -1171,7 +1568,14 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
1171
1568
|
pid: state.pid,
|
|
1172
1569
|
};
|
|
1173
1570
|
} else if (hasLock) {
|
|
1174
|
-
const outcome = await removeDeadOwnerLock(
|
|
1571
|
+
const outcome = await removeDeadOwnerLock(
|
|
1572
|
+
fs,
|
|
1573
|
+
paths,
|
|
1574
|
+
pidAlive,
|
|
1575
|
+
state,
|
|
1576
|
+
now,
|
|
1577
|
+
deps.pidIncarnation ?? defaultTransitionPidIncarnation,
|
|
1578
|
+
);
|
|
1175
1579
|
const action: DaemonRecoveryAction =
|
|
1176
1580
|
outcome === "cleared"
|
|
1177
1581
|
? "cleared-dead-owner-lock"
|
|
@@ -1190,9 +1594,35 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
1190
1594
|
: outcome === "superseded"
|
|
1191
1595
|
? "a new daemon owner took over during recovery; lock left untouched"
|
|
1192
1596
|
: outcome === "contended"
|
|
1193
|
-
?
|
|
1597
|
+
? `recovery blocked by transition marker; lock left untouched${opts.forceDaemonLock ? " even after forced stale-marker retry" : ""}`
|
|
1194
1598
|
: `could not remove lock of dead owner pid ${state.pid}`;
|
|
1195
|
-
|
|
1599
|
+
const blockingReason = outcome === "contended" ? "transition-marker-unavailable-or-contended" : undefined;
|
|
1600
|
+
const markerAgeMs =
|
|
1601
|
+
blockingReason && fs.stat
|
|
1602
|
+
? (await fs.stat(paths.steal).catch(() => undefined))?.mtimeMs
|
|
1603
|
+
? now() - (await fs.stat(paths.steal).catch(() => undefined))!.mtimeMs
|
|
1604
|
+
: undefined
|
|
1605
|
+
: undefined;
|
|
1606
|
+
if (blockingReason) {
|
|
1607
|
+
await writeNotificationDiagnostic(opts.settings, {
|
|
1608
|
+
operation: "notify.recovery",
|
|
1609
|
+
phase: "recovery",
|
|
1610
|
+
outcome: action,
|
|
1611
|
+
reason: blockingReason,
|
|
1612
|
+
pid: state.pid,
|
|
1613
|
+
ageMs: markerAgeMs,
|
|
1614
|
+
detail,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
daemon = {
|
|
1618
|
+
action,
|
|
1619
|
+
detail,
|
|
1620
|
+
ownerId: state.ownerId,
|
|
1621
|
+
pid: state.pid,
|
|
1622
|
+
...(blockingReason
|
|
1623
|
+
? { blockingReason, markerAgeMs, forceCommand: "gjc notify recovery --force-daemon-lock" }
|
|
1624
|
+
: {}),
|
|
1625
|
+
};
|
|
1196
1626
|
} else {
|
|
1197
1627
|
daemon = {
|
|
1198
1628
|
action: "none",
|
|
@@ -1232,5 +1662,9 @@ export function formatNotificationRecoveryReport(report: NotificationRecoveryRep
|
|
|
1232
1662
|
for (const unknown of report.endpointsRetainedUnknown ?? [])
|
|
1233
1663
|
lines.push(` - retained unverified cleanup path ${unknown}`);
|
|
1234
1664
|
lines.push(` daemon: ${report.daemon.action} — ${report.daemon.detail}`);
|
|
1665
|
+
if (report.daemon.blockingReason) lines.push(` - blocking reason: ${report.daemon.blockingReason}`);
|
|
1666
|
+
if (report.daemon.markerAgeMs !== undefined)
|
|
1667
|
+
lines.push(` - transition marker age: ${report.daemon.markerAgeMs}ms`);
|
|
1668
|
+
if (report.daemon.forceCommand) lines.push(` - safe escape: ${report.daemon.forceCommand}`);
|
|
1235
1669
|
return lines.join("\n");
|
|
1236
1670
|
}
|