@sayknow-cli/coding-agent 0.3.12 → 0.3.15
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/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
- package/dist/types/cli/classify-install-target.d.ts +0 -10
- package/dist/types/cli/daemon-cli.d.ts +2 -0
- package/dist/types/cli/initial-message.d.ts +1 -1
- package/dist/types/cli/malloc-env-guard.d.ts +65 -0
- package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +3 -1
- package/dist/types/cli/session-picker.d.ts +4 -3
- package/dist/types/cli/update-cli.d.ts +22 -3
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/commands/daemon.d.ts +6 -2
- package/dist/types/commands/launch.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/commands/web-search.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
- package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
- package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
- package/dist/types/commit/analysis/conventional.d.ts +1 -1
- package/dist/types/commit/analysis/summary.d.ts +1 -1
- package/dist/types/commit/changelog/generate.d.ts +3 -3
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
- package/dist/types/commit/shared-llm.d.ts +9 -9
- package/dist/types/config/keybindings.d.ts +134 -129
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +35 -35
- package/dist/types/config/models-config-schema.d.ts +111 -111
- package/dist/types/config/settings-schema.d.ts +56 -16
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +79 -6
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- package/dist/types/daemon/control-types.d.ts +10 -0
- package/dist/types/daemon/operator-contract.d.ts +47 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
- package/dist/types/edit/modes/patch.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/goals/tools/goal-tool.d.ts +6 -6
- package/dist/types/i18n/messages/en.d.ts +8 -8
- package/dist/types/internal-urls/local-protocol.d.ts +13 -9
- package/dist/types/lsp/index.d.ts +9 -9
- package/dist/types/lsp/types.d.ts +9 -9
- package/dist/types/main.d.ts +66 -2
- package/dist/types/modes/acp/acp-agent.d.ts +19 -2
- package/dist/types/modes/acp/acp-mode.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
- package/dist/types/modes/components/pet-selector.d.ts +11 -0
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
- package/dist/types/modes/components/session-selector.d.ts +22 -9
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +2 -1
- package/dist/types/modes/controllers/event-controller.d.ts +4 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +24 -2
- package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
- package/dist/types/modes/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
- package/dist/types/modes/theme/defaults/index.d.ts +568 -568
- package/dist/types/modes/types.d.ts +28 -2
- package/dist/types/modes/utils/abort-message.d.ts +1 -1
- package/dist/types/modes/utils/context-usage.d.ts +4 -19
- package/dist/types/modes/utils/irc-message.d.ts +38 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
- package/dist/types/notifications/config.d.ts +34 -0
- package/dist/types/notifications/daemon-paths.d.ts +7 -0
- package/dist/types/notifications/index.d.ts +27 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
- package/dist/types/notifications/notification-service.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
- package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +130 -3
- package/dist/types/notifications/telegram-reference.d.ts +19 -2
- package/dist/types/notifications/topic-registry.d.ts +24 -4
- package/dist/types/registry/agent-registry.d.ts +2 -0
- package/dist/types/rlm/index.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +40 -3
- package/dist/types/session/cache-economics.d.ts +79 -0
- package/dist/types/session/context-estimation.d.ts +31 -0
- package/dist/types/session/provider-safety-stop.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +124 -4
- package/dist/types/session/session-storage.d.ts +155 -13
- package/dist/types/setup/credential-auto-import.d.ts +2 -2
- package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
- package/dist/types/skc-runtime/state-schema.d.ts +19 -19
- package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
- package/dist/types/task/agents.d.ts +2 -2
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -27
- package/dist/types/tools/ask-answer-registry.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
- package/dist/types/tools/browser.d.ts +28 -28
- package/dist/types/tools/computer.d.ts +20 -20
- package/dist/types/tools/cron.d.ts +2 -2
- package/dist/types/tools/debug.d.ts +40 -40
- package/dist/types/tools/gh.d.ts +10 -10
- package/dist/types/tools/image-gen.d.ts +2 -2
- package/dist/types/tools/index.d.ts +58 -6
- package/dist/types/tools/monitor.d.ts +6 -6
- package/dist/types/tools/skill-discovery.d.ts +4 -4
- package/dist/types/tools/subagent.d.ts +12 -12
- package/dist/types/tools/telegram-send.d.ts +7 -0
- package/dist/types/tools/todo-write.d.ts +8 -8
- package/dist/types/utils/external-editor.d.ts +2 -0
- package/dist/types/web/insane/bridge.d.ts +9 -9
- package/dist/types/web/search/index.d.ts +4 -4
- package/package.json +8 -8
- package/scripts/g004-tmux-smoke.ts +196 -36
- package/src/cli/daemon-cli.ts +19 -29
- package/src/cli/malloc-env-guard.test.ts +104 -0
- package/src/cli/malloc-env-guard.ts +149 -0
- package/src/cli/notify-cli.ts +78 -14
- package/src/cli/session-picker.ts +23 -40
- package/src/cli/update-cli.ts +56 -37
- package/src/cli.ts +51 -10
- package/src/commands/daemon.ts +19 -5
- package/src/commands/harness.ts +515 -47
- package/src/commands/launch.ts +11 -3
- package/src/commands/notify.ts +5 -1
- package/src/commands/session.ts +6 -1
- package/src/config/keybindings.ts +5 -0
- package/src/config/model-profile-activation.ts +1 -1
- package/src/config/model-profiles.ts +14 -14
- package/src/config/settings-schema.ts +41 -1
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/model-preset.ts +100 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +2958 -430
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- package/src/daemon/control-types.ts +11 -0
- package/src/daemon/operator-contract.ts +121 -0
- package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
- package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
- package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/internal-urls/local-protocol.ts +29 -10
- package/src/main.ts +301 -80
- package/src/modes/acp/acp-agent.ts +730 -100
- package/src/modes/acp/acp-mode.ts +29 -2
- package/src/modes/components/assistant-message.ts +32 -3
- package/src/modes/components/bash-execution.ts +43 -13
- package/src/modes/components/custom-editor.ts +5 -0
- package/src/modes/components/footer.ts +3 -3
- package/src/modes/components/hook-selector.ts +12 -0
- package/src/modes/components/irc-sidebar.ts +206 -0
- package/src/modes/components/pet-selector.ts +55 -0
- package/src/modes/components/sayknow-pet-widget.ts +360 -0
- package/src/modes/components/session-selector.ts +222 -221
- package/src/modes/components/settings-selector.ts +10 -0
- package/src/modes/components/status-line/segments.ts +14 -7
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/components/user-message.ts +26 -9
- package/src/modes/controllers/command-controller.ts +28 -2
- package/src/modes/controllers/event-controller.ts +159 -36
- package/src/modes/controllers/extension-ui-controller.ts +37 -18
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +62 -15
- package/src/modes/interactive-mode.ts +199 -7
- package/src/modes/irc-observation-ledger.ts +127 -0
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
- package/src/modes/types.ts +34 -2
- package/src/modes/utils/context-usage.ts +56 -92
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/irc-message.ts +138 -0
- package/src/modes/utils/ui-helpers.ts +216 -50
- package/src/notifications/config.ts +44 -0
- package/src/notifications/daemon-paths.ts +8 -0
- package/src/notifications/index.ts +535 -75
- package/src/notifications/lifecycle-control-runtime.ts +930 -77
- package/src/notifications/notification-service.ts +710 -0
- package/src/notifications/rate-limit-pool.ts +46 -12
- package/src/notifications/telegram-daemon-control.ts +27 -2
- package/src/notifications/telegram-daemon.ts +635 -94
- package/src/notifications/telegram-reference.ts +73 -13
- package/src/notifications/topic-registry.ts +86 -9
- package/src/prompts/system/irc-peer-roster.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/registry/agent-registry.ts +3 -0
- package/src/sdk.ts +52 -1
- package/src/secrets/obfuscator.ts +4 -2
- package/src/session/agent-session.ts +670 -217
- package/src/session/cache-economics.ts +275 -0
- package/src/session/context-estimation.ts +91 -0
- package/src/session/provider-safety-stop.ts +9 -0
- package/src/session/session-dump-format.ts +6 -0
- package/src/session/session-manager.ts +809 -39
- package/src/session/session-storage.ts +582 -38
- package/src/skc-runtime/session-state-sidecar.ts +756 -135
- package/src/skc-runtime/tmux-common.ts +26 -7
- package/src/skc-runtime/tmux-gc.ts +52 -9
- package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
- package/src/skc-runtime/tmux-sessions.ts +871 -68
- package/src/slash-commands/builtin-registry.ts +153 -9
- package/src/slash-commands/helpers/context-report.ts +26 -4
- package/src/task/executor.ts +12 -3
- package/src/task/index.ts +8 -0
- package/src/task/receipt.ts +7 -2
- package/src/task/types.ts +26 -0
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/browser/tab-supervisor.ts +63 -6
- package/src/tools/browser.ts +8 -3
- package/src/tools/index.ts +81 -6
- package/src/tools/sqlite-reader.ts +8 -4
- package/src/tui/output-block.ts +18 -6
- package/src/utils/external-editor.ts +6 -1
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared notification service contract.
|
|
3
|
+
*
|
|
4
|
+
* Transport-agnostic, secret-safe operations consumed by BOTH the `skc notify`
|
|
5
|
+
* CLI and the cross-mode `/notify` slash command (TUI + ACP). Every result is
|
|
6
|
+
* free of raw secrets: bot tokens are only ever shown masked (`maskToken`) or
|
|
7
|
+
* as a non-reversible fingerprint (`tokenFingerprint`).
|
|
8
|
+
*
|
|
9
|
+
* Daemon-ownership protection: `recoverNotifications` only ever removes
|
|
10
|
+
* artifacts belonging to a DEAD owner (dead-PID / explicitly-stale). It never
|
|
11
|
+
* touches a live owner's lock/state and never kills a process.
|
|
12
|
+
*/
|
|
13
|
+
import * as fsPromises from "node:fs/promises";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import type { Settings } from "../config/settings";
|
|
16
|
+
import {
|
|
17
|
+
getNotificationConfig,
|
|
18
|
+
isGloballyConfigured,
|
|
19
|
+
isTelegramConfigured,
|
|
20
|
+
maskToken,
|
|
21
|
+
type NotificationConfig,
|
|
22
|
+
tokenFingerprint,
|
|
23
|
+
} from "./config";
|
|
24
|
+
import { type DaemonPaths, daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
|
|
25
|
+
import type { DaemonState } from "./telegram-daemon";
|
|
26
|
+
|
|
27
|
+
const DEFAULT_API_BASE = "https://api.telegram.org";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Telegram bot-token shape: `<digits>:<base64url-ish>`. Used to redact a
|
|
31
|
+
* token-shaped substring from a diagnostic even when the exact configured token
|
|
32
|
+
* is not known to the caller (e.g. a token echoed inside a fetch error URL).
|
|
33
|
+
*/
|
|
34
|
+
const TELEGRAM_TOKEN_PATTERN = /\d{6,}:[A-Za-z0-9_-]{20,}/g;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strip secrets from a human-facing diagnostic string. Redacts the configured
|
|
38
|
+
* bot token (exact match) and any token-shaped substring so health/test details
|
|
39
|
+
* can never leak a credential regardless of where the string originated.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeDiagnostic(text: string, token?: string): string {
|
|
42
|
+
let out = text;
|
|
43
|
+
const trimmed = token?.trim();
|
|
44
|
+
if (trimmed) out = out.split(trimmed).join("<redacted>");
|
|
45
|
+
return out.replace(TELEGRAM_TOKEN_PATTERN, "<redacted>");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal filesystem surface the service needs; injectable for tests. */
|
|
49
|
+
export interface NotificationServiceFs {
|
|
50
|
+
readdir(dir: string): Promise<string[]>;
|
|
51
|
+
readFile(file: string, encoding: "utf8"): Promise<string>;
|
|
52
|
+
unlink(file: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Atomically create `file` with O_EXCL semantics: resolves `true` when this
|
|
55
|
+
* call created it, `false` when it already existed. Used to hold the daemon
|
|
56
|
+
* steal-mutex ({@link DaemonPaths.steal}) during owner-bound lock removal so
|
|
57
|
+
* recovery and a concurrent daemon takeover are mutually exclusive.
|
|
58
|
+
*/
|
|
59
|
+
createExclusive(file: string): Promise<boolean>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nodeServiceFs: NotificationServiceFs = {
|
|
63
|
+
readdir: dir => fsPromises.readdir(dir),
|
|
64
|
+
readFile: (file, encoding) => fsPromises.readFile(file, encoding),
|
|
65
|
+
unlink: file => fsPromises.unlink(file),
|
|
66
|
+
createExclusive: async file => {
|
|
67
|
+
try {
|
|
68
|
+
const handle = await fsPromises.open(file, "wx", 0o600);
|
|
69
|
+
await handle.close();
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Injectable dependencies shared across service operations. */
|
|
78
|
+
export interface NotificationServiceDeps {
|
|
79
|
+
fs?: NotificationServiceFs;
|
|
80
|
+
now?: () => number;
|
|
81
|
+
pidAlive?: (pid: number) => boolean;
|
|
82
|
+
fetchImpl?: typeof fetch;
|
|
83
|
+
apiBase?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function defaultPidAlive(pid: number): boolean {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// EPERM means the process exists but is owned by another user: still alive.
|
|
92
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function defaultStateRoot(): string {
|
|
97
|
+
return path.join(process.cwd(), ".skc", "state");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function endpointDir(stateRoot: string): string {
|
|
101
|
+
return path.join(stateRoot, "notifications");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- status -------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export interface AdapterConfigView {
|
|
107
|
+
botTokenMasked: string;
|
|
108
|
+
channel: string | undefined;
|
|
109
|
+
configured: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface NotificationStatusReport {
|
|
113
|
+
enabled: boolean;
|
|
114
|
+
redact: boolean;
|
|
115
|
+
verbosity: "lean" | "verbose";
|
|
116
|
+
globallyConfigured: boolean;
|
|
117
|
+
telegram: AdapterConfigView & { tokenFingerprint: string | undefined };
|
|
118
|
+
discord: AdapterConfigView;
|
|
119
|
+
slack: AdapterConfigView;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function adapterConfigured(token: string | undefined, channel: string | undefined): boolean {
|
|
123
|
+
return Boolean(token?.trim()) && Boolean(channel?.trim());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Build a secret-safe structured status snapshot from settings. */
|
|
127
|
+
export function buildNotificationStatusReport(settings: Settings): NotificationStatusReport {
|
|
128
|
+
const cfg = getNotificationConfig(settings);
|
|
129
|
+
return {
|
|
130
|
+
enabled: cfg.enabled,
|
|
131
|
+
redact: cfg.redact,
|
|
132
|
+
verbosity: cfg.verbosity,
|
|
133
|
+
globallyConfigured: isGloballyConfigured(cfg),
|
|
134
|
+
telegram: {
|
|
135
|
+
botTokenMasked: maskToken(cfg.botToken),
|
|
136
|
+
channel: cfg.chatId,
|
|
137
|
+
configured: isTelegramConfigured(cfg),
|
|
138
|
+
tokenFingerprint: cfg.botToken?.trim() ? tokenFingerprint(cfg.botToken) : undefined,
|
|
139
|
+
},
|
|
140
|
+
discord: {
|
|
141
|
+
botTokenMasked: maskToken(cfg.discord.botToken),
|
|
142
|
+
channel: cfg.discord.channelId,
|
|
143
|
+
configured: adapterConfigured(cfg.discord.botToken, cfg.discord.channelId),
|
|
144
|
+
},
|
|
145
|
+
slack: {
|
|
146
|
+
botTokenMasked: maskToken(cfg.slack.botToken),
|
|
147
|
+
channel: cfg.slack.channelId,
|
|
148
|
+
configured: adapterConfigured(cfg.slack.botToken, cfg.slack.channelId),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Render a status report as human-readable lines (no secrets). */
|
|
154
|
+
export function formatNotificationStatusReport(report: NotificationStatusReport): string {
|
|
155
|
+
const yesNo = (v: boolean): string => (v ? "yes" : "no");
|
|
156
|
+
return [
|
|
157
|
+
"Notifications",
|
|
158
|
+
` enabled: ${report.enabled}`,
|
|
159
|
+
` globally configured: ${yesNo(report.globallyConfigured)}`,
|
|
160
|
+
` redact: ${report.redact}`,
|
|
161
|
+
` verbosity: ${report.verbosity}`,
|
|
162
|
+
` telegram.botToken: ${report.telegram.botTokenMasked}`,
|
|
163
|
+
` telegram.chatId: ${report.telegram.channel ?? "(unset)"}`,
|
|
164
|
+
` telegram.fingerprint: ${report.telegram.tokenFingerprint ?? "(unset)"}`,
|
|
165
|
+
` telegram.configured: ${yesNo(report.telegram.configured)}`,
|
|
166
|
+
` discord.botToken: ${report.discord.botTokenMasked}`,
|
|
167
|
+
` discord.channelId: ${report.discord.channel ?? "(unset)"}`,
|
|
168
|
+
` slack.botToken: ${report.slack.botTokenMasked}`,
|
|
169
|
+
` slack.channelId: ${report.slack.channel ?? "(unset)"}`,
|
|
170
|
+
].join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- endpoint / daemon file readers -------------------------------------
|
|
174
|
+
|
|
175
|
+
interface EndpointView {
|
|
176
|
+
sessionId: string;
|
|
177
|
+
pid: number | undefined;
|
|
178
|
+
stale: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type EndpointLiveness = "live" | "dead" | "unknown";
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Classify an endpoint using owner-proof semantics. An endpoint is only `dead`
|
|
185
|
+
* with positive proof: an explicit `stale` tombstone, or a recorded pid that is
|
|
186
|
+
* confirmed not alive. A PID-less endpoint is `unknown` (not provably dead) and
|
|
187
|
+
* must never be treated as dead — removing it could delete a live session's
|
|
188
|
+
* discovery file that simply omitted a pid.
|
|
189
|
+
*/
|
|
190
|
+
function endpointLiveness(view: EndpointView, pidAlive: (pid: number) => boolean): EndpointLiveness {
|
|
191
|
+
if (view.stale) return "dead";
|
|
192
|
+
if (view.pid === undefined) return "unknown";
|
|
193
|
+
return pidAlive(view.pid) ? "live" : "dead";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function readEndpointView(fs: NotificationServiceFs, file: string): Promise<EndpointView | undefined> {
|
|
197
|
+
let raw: string;
|
|
198
|
+
try {
|
|
199
|
+
raw = await fs.readFile(file, "utf8");
|
|
200
|
+
} catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
let parsed: unknown;
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(raw);
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
210
|
+
const rec = parsed as Record<string, unknown>;
|
|
211
|
+
return {
|
|
212
|
+
sessionId: typeof rec.sessionId === "string" ? rec.sessionId : path.basename(file, ".json"),
|
|
213
|
+
pid: typeof rec.pid === "number" ? rec.pid : undefined,
|
|
214
|
+
stale: rec.stale === true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseDaemonState(raw: string): DaemonState | undefined {
|
|
219
|
+
let parsed: unknown;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(raw);
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
226
|
+
const rec = parsed as Record<string, unknown>;
|
|
227
|
+
if (typeof rec.pid !== "number" || typeof rec.ownerId !== "string") return undefined;
|
|
228
|
+
return parsed as DaemonState;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function readDaemonStateFile(fs: NotificationServiceFs, file: string): Promise<DaemonState | undefined> {
|
|
232
|
+
try {
|
|
233
|
+
return parseDaemonState(await fs.readFile(file, "utf8"));
|
|
234
|
+
} catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function listEndpointFiles(fs: NotificationServiceFs, dir: string): Promise<string[]> {
|
|
240
|
+
try {
|
|
241
|
+
return (await fs.readdir(dir)).filter(name => name.endsWith(".json"));
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- health -------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
export type HealthLevel = "ok" | "warn" | "error";
|
|
250
|
+
|
|
251
|
+
export interface HealthCheck {
|
|
252
|
+
name: string;
|
|
253
|
+
level: HealthLevel;
|
|
254
|
+
detail: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface DaemonHealth {
|
|
258
|
+
present: boolean;
|
|
259
|
+
ownerId: string | undefined;
|
|
260
|
+
pid: number | undefined;
|
|
261
|
+
alive: boolean;
|
|
262
|
+
heartbeatFresh: boolean;
|
|
263
|
+
identityMatches: boolean;
|
|
264
|
+
stopped: boolean;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface EndpointHealth {
|
|
268
|
+
total: number;
|
|
269
|
+
live: number;
|
|
270
|
+
dead: number;
|
|
271
|
+
unknown: number;
|
|
272
|
+
unreadable: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface NotificationHealthReport {
|
|
276
|
+
overall: HealthLevel;
|
|
277
|
+
configured: boolean;
|
|
278
|
+
checks: HealthCheck[];
|
|
279
|
+
daemon: DaemonHealth;
|
|
280
|
+
endpoints: EndpointHealth;
|
|
281
|
+
reachability: { probed: boolean; ok: boolean; detail: string };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface HealthOptions {
|
|
285
|
+
settings: Settings;
|
|
286
|
+
stateRoot?: string;
|
|
287
|
+
deps?: NotificationServiceDeps;
|
|
288
|
+
/** When true and Telegram is configured, probe the Bot API (getMe) for reachability. */
|
|
289
|
+
probe?: boolean;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function probeTelegramReachability(
|
|
293
|
+
fetchImpl: typeof fetch,
|
|
294
|
+
apiBase: string,
|
|
295
|
+
token: string,
|
|
296
|
+
): Promise<{ ok: boolean; detail: string }> {
|
|
297
|
+
try {
|
|
298
|
+
const response = await fetchImpl(`${apiBase.replace(/\/$/, "")}/bot${token}/getMe`, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: { "content-type": "application/json" },
|
|
301
|
+
body: "{}",
|
|
302
|
+
});
|
|
303
|
+
const payload = (await response.json().catch(() => undefined)) as
|
|
304
|
+
| { ok?: boolean; description?: string; result?: { username?: string } }
|
|
305
|
+
| undefined;
|
|
306
|
+
if (response.ok && payload?.ok) {
|
|
307
|
+
const username = payload.result?.username;
|
|
308
|
+
return { ok: true, detail: username ? `reachable as @${username}` : "reachable" };
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
detail: sanitizeDiagnostic(payload?.description ?? `Telegram getMe failed (HTTP ${response.status})`, token),
|
|
313
|
+
};
|
|
314
|
+
} catch (err) {
|
|
315
|
+
return { ok: false, detail: sanitizeDiagnostic(err instanceof Error ? err.message : "network error", token) };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function worst(a: HealthLevel, b: HealthLevel): HealthLevel {
|
|
320
|
+
const rank: Record<HealthLevel, number> = { ok: 0, warn: 1, error: 2 };
|
|
321
|
+
return rank[a] >= rank[b] ? a : b;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Structural (offline-by-default) health of the notification subsystem. */
|
|
325
|
+
export async function checkNotificationHealth(opts: HealthOptions): Promise<NotificationHealthReport> {
|
|
326
|
+
const deps = opts.deps ?? {};
|
|
327
|
+
const fs = deps.fs ?? nodeServiceFs;
|
|
328
|
+
const now = (deps.now ?? Date.now)();
|
|
329
|
+
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
330
|
+
const stateRoot = opts.stateRoot ?? defaultStateRoot();
|
|
331
|
+
|
|
332
|
+
const cfg: NotificationConfig = getNotificationConfig(opts.settings);
|
|
333
|
+
const configured = isGloballyConfigured(cfg);
|
|
334
|
+
const telegramConfigured = isTelegramConfigured(cfg);
|
|
335
|
+
const checks: HealthCheck[] = [];
|
|
336
|
+
|
|
337
|
+
if (!cfg.enabled) {
|
|
338
|
+
checks.push({
|
|
339
|
+
name: "config",
|
|
340
|
+
level: "warn",
|
|
341
|
+
detail: "notifications are disabled (notifications.enabled=false)",
|
|
342
|
+
});
|
|
343
|
+
} else if (!configured) {
|
|
344
|
+
checks.push({ name: "config", level: "warn", detail: "no notification adapter is fully configured" });
|
|
345
|
+
} else {
|
|
346
|
+
checks.push({ name: "config", level: "ok", detail: "enabled with at least one configured adapter" });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Daemon ownership state (offline; read the persisted state file directly).
|
|
350
|
+
const paths = daemonPaths(opts.settings.getAgentDir());
|
|
351
|
+
const state = await readDaemonStateFile(fs, paths.state);
|
|
352
|
+
const daemon: DaemonHealth = {
|
|
353
|
+
present: Boolean(state),
|
|
354
|
+
ownerId: state?.ownerId,
|
|
355
|
+
pid: state?.pid,
|
|
356
|
+
alive: state ? pidAlive(state.pid) : false,
|
|
357
|
+
heartbeatFresh: state ? now - state.heartbeatAt <= HEARTBEAT_TTL_MS : false,
|
|
358
|
+
identityMatches:
|
|
359
|
+
Boolean(state) &&
|
|
360
|
+
telegramConfigured &&
|
|
361
|
+
state?.tokenFingerprint === tokenFingerprint(cfg.botToken) &&
|
|
362
|
+
state?.chatId === cfg.chatId,
|
|
363
|
+
stopped: typeof state?.stoppedAt === "number",
|
|
364
|
+
};
|
|
365
|
+
if (!state) {
|
|
366
|
+
checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
|
|
367
|
+
} else if (!daemon.alive) {
|
|
368
|
+
checks.push({
|
|
369
|
+
name: "daemon",
|
|
370
|
+
level: "warn",
|
|
371
|
+
detail: `daemon owner pid ${daemon.pid} is not alive; run recovery to clear the stale lock`,
|
|
372
|
+
});
|
|
373
|
+
} else if (!daemon.heartbeatFresh) {
|
|
374
|
+
checks.push({ name: "daemon", level: "warn", detail: `daemon pid ${daemon.pid} heartbeat is stale` });
|
|
375
|
+
} else if (telegramConfigured && !daemon.identityMatches) {
|
|
376
|
+
checks.push({
|
|
377
|
+
name: "daemon",
|
|
378
|
+
level: "warn",
|
|
379
|
+
detail: "a live daemon owns a different bot token or chat id",
|
|
380
|
+
});
|
|
381
|
+
} else {
|
|
382
|
+
checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Per-session endpoint discovery files.
|
|
386
|
+
const dir = endpointDir(stateRoot);
|
|
387
|
+
const files = await listEndpointFiles(fs, dir);
|
|
388
|
+
let live = 0;
|
|
389
|
+
let dead = 0;
|
|
390
|
+
let unknownEndpoints = 0;
|
|
391
|
+
let unreadable = 0;
|
|
392
|
+
for (const name of files) {
|
|
393
|
+
const view = await readEndpointView(fs, path.join(dir, name));
|
|
394
|
+
if (!view) {
|
|
395
|
+
unreadable += 1;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
switch (endpointLiveness(view, pidAlive)) {
|
|
399
|
+
case "live":
|
|
400
|
+
live += 1;
|
|
401
|
+
break;
|
|
402
|
+
case "dead":
|
|
403
|
+
dead += 1;
|
|
404
|
+
break;
|
|
405
|
+
default:
|
|
406
|
+
unknownEndpoints += 1;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const endpoints: EndpointHealth = { total: files.length, live, dead, unknown: unknownEndpoints, unreadable };
|
|
411
|
+
if (dead > 0 || unreadable > 0) {
|
|
412
|
+
checks.push({
|
|
413
|
+
name: "endpoints",
|
|
414
|
+
level: "warn",
|
|
415
|
+
detail: `${dead} dead / ${unreadable} unreadable of ${files.length} endpoint file(s); run recovery`,
|
|
416
|
+
});
|
|
417
|
+
} else {
|
|
418
|
+
checks.push({
|
|
419
|
+
name: "endpoints",
|
|
420
|
+
level: "ok",
|
|
421
|
+
detail: `${live} live, ${unknownEndpoints} unverified endpoint file(s)`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Optional network reachability probe.
|
|
426
|
+
let reachability = { probed: false, ok: false, detail: "not probed" };
|
|
427
|
+
if (opts.probe && telegramConfigured) {
|
|
428
|
+
const result = await probeTelegramReachability(
|
|
429
|
+
deps.fetchImpl ?? globalThis.fetch,
|
|
430
|
+
deps.apiBase ?? DEFAULT_API_BASE,
|
|
431
|
+
cfg.botToken,
|
|
432
|
+
);
|
|
433
|
+
reachability = { probed: true, ...result };
|
|
434
|
+
checks.push({
|
|
435
|
+
name: "reachability",
|
|
436
|
+
level: result.ok ? "ok" : "error",
|
|
437
|
+
detail: `Telegram: ${result.detail}`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const overall = checks.reduce<HealthLevel>((acc, check) => worst(acc, check.level), "ok");
|
|
442
|
+
return { overall, configured, checks, daemon, endpoints, reachability };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Render a health report as human-readable lines (no secrets). */
|
|
446
|
+
export function formatNotificationHealthReport(report: NotificationHealthReport): string {
|
|
447
|
+
const icon: Record<HealthLevel, string> = { ok: "[ok]", warn: "[warn]", error: "[error]" };
|
|
448
|
+
const lines = [`Notification health: ${report.overall.toUpperCase()}`];
|
|
449
|
+
for (const check of report.checks) {
|
|
450
|
+
lines.push(` ${icon[check.level]} ${check.name}: ${check.detail}`);
|
|
451
|
+
}
|
|
452
|
+
return lines.join("\n");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- test ---------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
export interface NotificationTestResult {
|
|
458
|
+
ok: boolean;
|
|
459
|
+
adapter: "telegram";
|
|
460
|
+
chatId: string | undefined;
|
|
461
|
+
detail: string;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface TestOptions {
|
|
465
|
+
settings: Settings;
|
|
466
|
+
deps?: NotificationServiceDeps;
|
|
467
|
+
text?: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Send a one-off test notification through the configured Telegram adapter. */
|
|
471
|
+
export async function sendNotificationTest(opts: TestOptions): Promise<NotificationTestResult> {
|
|
472
|
+
const deps = opts.deps ?? {};
|
|
473
|
+
const cfg = getNotificationConfig(opts.settings);
|
|
474
|
+
if (!isTelegramConfigured(cfg)) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
adapter: "telegram",
|
|
478
|
+
chatId: cfg.chatId,
|
|
479
|
+
detail: "Telegram is not configured (need notifications.enabled + botToken + chatId). Run `skc notify setup`.",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
483
|
+
const apiBase = (deps.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
484
|
+
const text = opts.text ?? "SKC notifications test message. If you can read this, delivery works.";
|
|
485
|
+
try {
|
|
486
|
+
const response = await fetchImpl(`${apiBase}/bot${cfg.botToken}/sendMessage`, {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: { "content-type": "application/json" },
|
|
489
|
+
body: JSON.stringify({ chat_id: cfg.chatId, text }),
|
|
490
|
+
});
|
|
491
|
+
const payload = (await response.json().catch(() => undefined)) as
|
|
492
|
+
| { ok?: boolean; description?: string }
|
|
493
|
+
| undefined;
|
|
494
|
+
if (response.ok && payload?.ok) {
|
|
495
|
+
return { ok: true, adapter: "telegram", chatId: cfg.chatId, detail: `delivered to chat ${cfg.chatId}` };
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
adapter: "telegram",
|
|
500
|
+
chatId: cfg.chatId,
|
|
501
|
+
detail: sanitizeDiagnostic(
|
|
502
|
+
payload?.description ?? `Telegram sendMessage failed (HTTP ${response.status})`,
|
|
503
|
+
cfg.botToken,
|
|
504
|
+
),
|
|
505
|
+
};
|
|
506
|
+
} catch (err) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
adapter: "telegram",
|
|
510
|
+
chatId: cfg.chatId,
|
|
511
|
+
detail: sanitizeDiagnostic(err instanceof Error ? err.message : "network error", cfg.botToken),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Render a test result as a single human-readable line (no secrets). */
|
|
517
|
+
export function formatNotificationTestResult(result: NotificationTestResult): string {
|
|
518
|
+
return `Notification test (${result.adapter}): ${result.ok ? "OK" : "FAILED"} — ${result.detail}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// --- recovery -----------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
export interface RecoveredEndpoint {
|
|
524
|
+
sessionId: string;
|
|
525
|
+
pid: number | undefined;
|
|
526
|
+
reason: "stale-flag" | "dead-pid";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export type DaemonRecoveryAction =
|
|
530
|
+
| "none"
|
|
531
|
+
| "cleared-dead-owner-lock"
|
|
532
|
+
| "left-active"
|
|
533
|
+
| "left-contended"
|
|
534
|
+
| "owner-superseded"
|
|
535
|
+
| "orphan-lock-left";
|
|
536
|
+
|
|
537
|
+
export interface NotificationRecoveryReport {
|
|
538
|
+
endpointsScanned: number;
|
|
539
|
+
endpointsRemoved: RecoveredEndpoint[];
|
|
540
|
+
endpointsKept: number;
|
|
541
|
+
endpointsUnreadable: number;
|
|
542
|
+
daemon: {
|
|
543
|
+
action: DaemonRecoveryAction;
|
|
544
|
+
detail: string;
|
|
545
|
+
ownerId: string | undefined;
|
|
546
|
+
pid: number | undefined;
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export interface RecoveryOptions {
|
|
551
|
+
settings: Settings;
|
|
552
|
+
stateRoot?: string;
|
|
553
|
+
deps?: NotificationServiceDeps;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Owner-bound removal of a dead daemon's lock. Closes the classic
|
|
558
|
+
* check-then-unlink TOCTOU: a naive `unlink(lock)` after observing a dead owner
|
|
559
|
+
* can delete a *new* live owner's lock if a daemon took over in between. This
|
|
560
|
+
* primitive re-checks ownership while holding the same steal-mutex the daemon's
|
|
561
|
+
* own takeover path uses ({@link DaemonPaths.steal}), so the two are mutually
|
|
562
|
+
* exclusive, and unlinks only when the recorded owner is still the same
|
|
563
|
+
* confirmed-dead process.
|
|
564
|
+
*/
|
|
565
|
+
async function removeDeadOwnerLock(
|
|
566
|
+
fs: NotificationServiceFs,
|
|
567
|
+
paths: DaemonPaths,
|
|
568
|
+
pidAlive: (pid: number) => boolean,
|
|
569
|
+
expected: DaemonState,
|
|
570
|
+
): Promise<"cleared" | "contended" | "superseded" | "now-alive" | "unlink-failed"> {
|
|
571
|
+
if (!(await fs.createExclusive(paths.steal))) return "contended";
|
|
572
|
+
try {
|
|
573
|
+
const current = await readDaemonStateFile(fs, paths.state);
|
|
574
|
+
if (!current || current.ownerId !== expected.ownerId || current.pid !== expected.pid) {
|
|
575
|
+
return "superseded";
|
|
576
|
+
}
|
|
577
|
+
if (pidAlive(current.pid)) return "now-alive";
|
|
578
|
+
try {
|
|
579
|
+
await fs.unlink(paths.lock);
|
|
580
|
+
return "cleared";
|
|
581
|
+
} catch {
|
|
582
|
+
return "unlink-failed";
|
|
583
|
+
}
|
|
584
|
+
} finally {
|
|
585
|
+
await fs.unlink(paths.steal).catch(() => undefined);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Ownership-protected cleanup. Removes only DEAD-owner artifacts:
|
|
591
|
+
* per-session endpoint files with positive proof of death (a stale tombstone or
|
|
592
|
+
* a dead recorded pid), and a daemon lock whose recorded owner is confirmed
|
|
593
|
+
* dead. A PID-less endpoint is treated as unknown (not dead) and kept. The
|
|
594
|
+
* daemon lock is removed through {@link removeDeadOwnerLock}, an owner-bound
|
|
595
|
+
* primitive that re-checks ownership under the daemon steal-mutex so it can
|
|
596
|
+
* never race a concurrent takeover. Never removes a live owner's lock, never
|
|
597
|
+
* deletes unreadable files, and never kills a process.
|
|
598
|
+
*/
|
|
599
|
+
export async function recoverNotifications(opts: RecoveryOptions): Promise<NotificationRecoveryReport> {
|
|
600
|
+
const deps = opts.deps ?? {};
|
|
601
|
+
const fs = deps.fs ?? nodeServiceFs;
|
|
602
|
+
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
603
|
+
const stateRoot = opts.stateRoot ?? defaultStateRoot();
|
|
604
|
+
|
|
605
|
+
const dir = endpointDir(stateRoot);
|
|
606
|
+
const files = await listEndpointFiles(fs, dir);
|
|
607
|
+
const removed: RecoveredEndpoint[] = [];
|
|
608
|
+
let kept = 0;
|
|
609
|
+
let unreadable = 0;
|
|
610
|
+
for (const name of files) {
|
|
611
|
+
const view = await readEndpointView(fs, path.join(dir, name));
|
|
612
|
+
if (!view) {
|
|
613
|
+
// Leave unparseable files untouched: they may be mid-write by a live server.
|
|
614
|
+
unreadable += 1;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (endpointLiveness(view, pidAlive) !== "dead") {
|
|
618
|
+
// Keep live AND unknown (PID-less) endpoints: only positive proof of
|
|
619
|
+
// death (a stale tombstone or a dead pid) authorizes removal.
|
|
620
|
+
kept += 1;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
await fs.unlink(path.join(dir, name));
|
|
625
|
+
removed.push({ sessionId: view.sessionId, pid: view.pid, reason: view.stale ? "stale-flag" : "dead-pid" });
|
|
626
|
+
} catch {
|
|
627
|
+
kept += 1;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Daemon lock: clear only when the recorded owner process is dead.
|
|
632
|
+
const paths = daemonPaths(opts.settings.getAgentDir());
|
|
633
|
+
let daemonFiles: string[] = [];
|
|
634
|
+
try {
|
|
635
|
+
daemonFiles = await fs.readdir(paths.dir);
|
|
636
|
+
} catch {
|
|
637
|
+
// directory absent: nothing to recover
|
|
638
|
+
}
|
|
639
|
+
const hasLock = daemonFiles.includes(path.basename(paths.lock));
|
|
640
|
+
const state = await readDaemonStateFile(fs, paths.state);
|
|
641
|
+
let daemon: NotificationRecoveryReport["daemon"];
|
|
642
|
+
if (!state) {
|
|
643
|
+
daemon = hasLock
|
|
644
|
+
? {
|
|
645
|
+
action: "orphan-lock-left",
|
|
646
|
+
detail: "daemon lock present without an ownership record; left untouched to protect a starting owner",
|
|
647
|
+
ownerId: undefined,
|
|
648
|
+
pid: undefined,
|
|
649
|
+
}
|
|
650
|
+
: { action: "none", detail: "no daemon ownership record", ownerId: undefined, pid: undefined };
|
|
651
|
+
} else if (pidAlive(state.pid)) {
|
|
652
|
+
daemon = {
|
|
653
|
+
action: "left-active",
|
|
654
|
+
detail: `live daemon owned by pid ${state.pid} left untouched`,
|
|
655
|
+
ownerId: state.ownerId,
|
|
656
|
+
pid: state.pid,
|
|
657
|
+
};
|
|
658
|
+
} else if (hasLock) {
|
|
659
|
+
const outcome = await removeDeadOwnerLock(fs, paths, pidAlive, state);
|
|
660
|
+
const action: DaemonRecoveryAction =
|
|
661
|
+
outcome === "cleared"
|
|
662
|
+
? "cleared-dead-owner-lock"
|
|
663
|
+
: outcome === "now-alive"
|
|
664
|
+
? "left-active"
|
|
665
|
+
: outcome === "superseded"
|
|
666
|
+
? "owner-superseded"
|
|
667
|
+
: outcome === "contended"
|
|
668
|
+
? "left-contended"
|
|
669
|
+
: "orphan-lock-left";
|
|
670
|
+
const detail =
|
|
671
|
+
outcome === "cleared"
|
|
672
|
+
? `cleared lock of dead owner pid ${state.pid}`
|
|
673
|
+
: outcome === "now-alive"
|
|
674
|
+
? `owner pid ${state.pid} became live during recovery; lock left untouched`
|
|
675
|
+
: outcome === "superseded"
|
|
676
|
+
? "a new daemon owner took over during recovery; lock left untouched"
|
|
677
|
+
: outcome === "contended"
|
|
678
|
+
? "another daemon is starting or stealing the lock; lock left untouched"
|
|
679
|
+
: `could not remove lock of dead owner pid ${state.pid}`;
|
|
680
|
+
daemon = { action, detail, ownerId: state.ownerId, pid: state.pid };
|
|
681
|
+
} else {
|
|
682
|
+
daemon = {
|
|
683
|
+
action: "none",
|
|
684
|
+
detail: `dead owner pid ${state.pid} recorded but no lock present`,
|
|
685
|
+
ownerId: state.ownerId,
|
|
686
|
+
pid: state.pid,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
endpointsScanned: files.length,
|
|
692
|
+
endpointsRemoved: removed,
|
|
693
|
+
endpointsKept: kept,
|
|
694
|
+
endpointsUnreadable: unreadable,
|
|
695
|
+
daemon,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Render a recovery report as human-readable lines (no secrets). */
|
|
700
|
+
export function formatNotificationRecoveryReport(report: NotificationRecoveryReport): string {
|
|
701
|
+
const lines = ["Notification recovery"];
|
|
702
|
+
lines.push(
|
|
703
|
+
` endpoints: scanned ${report.endpointsScanned}, removed ${report.endpointsRemoved.length}, kept ${report.endpointsKept}, unreadable ${report.endpointsUnreadable}`,
|
|
704
|
+
);
|
|
705
|
+
for (const ep of report.endpointsRemoved) {
|
|
706
|
+
lines.push(` - removed ${ep.sessionId} (pid ${ep.pid ?? "?"}, ${ep.reason})`);
|
|
707
|
+
}
|
|
708
|
+
lines.push(` daemon: ${report.daemon.action} — ${report.daemon.detail}`);
|
|
709
|
+
return lines.join("\n");
|
|
710
|
+
}
|