@kal-elsam/kairo-runtime 0.8.0 → 0.9.0
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/README.md +31 -11
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +4 -4
- package/src/cli.js +24 -1
- package/src/global/global-doctor.js +2 -0
- package/src/global/ink/cockpit/primitives.js +96 -31
- package/src/global/ink/cockpit-alerts.js +36 -0
- package/src/global/ink/cockpit-changes.js +59 -35
- package/src/global/ink/cockpit-control-center.js +129 -51
- package/src/global/ink/cockpit-controller.js +58 -11
- package/src/global/ink/cockpit-focus.js +4 -2
- package/src/global/ink/cockpit-models.js +89 -47
- package/src/global/ink/cockpit-palette.js +98 -0
- package/src/global/ink/cockpit-path-label.js +19 -0
- package/src/global/ink/cockpit-recovery.js +78 -15
- package/src/global/ink/cockpit-reviews.js +14 -10
- package/src/global/ink/cockpit-runs.js +13 -4
- package/src/global/ink/cockpit-settings.js +194 -0
- package/src/global/ink/cockpit-views.js +88 -57
- package/src/global/ink/orchestrator-app.js +137 -46
- package/src/global/ink/orchestrator-state.js +24 -14
- package/src/global/ink/use-orchestrator-data.js +58 -0
- package/src/global/paths.js +3 -0
- package/src/global/runtime/alerts/alert-store.js +216 -0
- package/src/global/runtime/alerts/alert-types.js +59 -0
- package/src/global/runtime/alerts/alert-validate.js +117 -0
- package/src/global/runtime/monitor/monitor-cli.js +62 -0
- package/src/global/runtime/monitor/monitor-platform.js +95 -0
- package/src/global/runtime/monitor/monitor.js +249 -0
|
@@ -33,8 +33,14 @@ import {
|
|
|
33
33
|
listRecoverySnapshots,
|
|
34
34
|
reduceRecoveryAction
|
|
35
35
|
} from "./cockpit-recovery.js";
|
|
36
|
+
import {
|
|
37
|
+
createSettingsActionState,
|
|
38
|
+
listCuratedIntegrations,
|
|
39
|
+
reduceSettingsAction
|
|
40
|
+
} from "./cockpit-settings.js";
|
|
36
41
|
import { listReviewReceipts } from "../runtime/review/review-receipts.js";
|
|
37
42
|
import { assertReceiptSecretFree } from "../runtime/review/review-validate.js";
|
|
43
|
+
import { listAlerts, resolveAlert, dismissAlert } from "../runtime/alerts/alert-store.js";
|
|
38
44
|
|
|
39
45
|
export function useOrchestratorData({
|
|
40
46
|
homeDir,
|
|
@@ -54,6 +60,7 @@ export function useOrchestratorData({
|
|
|
54
60
|
const [selectedEvents, setSelectedEvents] = useState([]);
|
|
55
61
|
const [reviews, setReviews] = useState([]);
|
|
56
62
|
const [selectedReview, setSelectedReview] = useState(null);
|
|
63
|
+
const [alerts, setAlerts] = useState(null);
|
|
57
64
|
const [statusMessage, setStatusMessage] = useState(null);
|
|
58
65
|
const [launchAgentIndex, setLaunchAgentIndex] = useState(0);
|
|
59
66
|
const [launchStep, setLaunchStep] = useState(LAUNCH_WIZARD_STEPS.AGENT);
|
|
@@ -61,6 +68,7 @@ export function useOrchestratorData({
|
|
|
61
68
|
const [launchPermissionIndex, setLaunchPermissionIndex] = useState(0);
|
|
62
69
|
const [changesAction, setChangesAction] = useState(createChangesActionState);
|
|
63
70
|
const [recoveryAction, setRecoveryAction] = useState(createRecoveryActionState);
|
|
71
|
+
const [settingsAction, setSettingsAction] = useState(createSettingsActionState);
|
|
64
72
|
|
|
65
73
|
const serializedReload = useMemo(() => createSerializedReloader(() => loadCockpitScanBundle({
|
|
66
74
|
homeDir,
|
|
@@ -90,6 +98,11 @@ export function useOrchestratorData({
|
|
|
90
98
|
setDashboard(outcome.result.dashboard);
|
|
91
99
|
setDiagnostics(outcome.result.diagnostics);
|
|
92
100
|
setSnapshot(outcome.result.snapshot);
|
|
101
|
+
try {
|
|
102
|
+
setAlerts(await listAlerts({ homeDir, limit: 50 }));
|
|
103
|
+
} catch {
|
|
104
|
+
setAlerts(null);
|
|
105
|
+
}
|
|
93
106
|
setError(null);
|
|
94
107
|
setLoading(false);
|
|
95
108
|
setRetrying(false);
|
|
@@ -149,6 +162,22 @@ export function useOrchestratorData({
|
|
|
149
162
|
});
|
|
150
163
|
};
|
|
151
164
|
|
|
165
|
+
const handleAlertTransition = async (alert, action) => {
|
|
166
|
+
if (!alert) return;
|
|
167
|
+
setBusy(true);
|
|
168
|
+
try {
|
|
169
|
+
if (action === "dismiss") await dismissAlert(alert.alertId, { homeDir });
|
|
170
|
+
else await resolveAlert(alert.alertId, { homeDir });
|
|
171
|
+
setAlerts(await listAlerts({ homeDir, limit: 50 }));
|
|
172
|
+
setStatusMessage(action === "dismiss" ? "Alert dismissed" : "Alert resolved");
|
|
173
|
+
} catch (error) {
|
|
174
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
175
|
+
setAlerts(null);
|
|
176
|
+
} finally {
|
|
177
|
+
setBusy(false);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
152
181
|
const handleLaunch = async (draft, profile, dispatch) => {
|
|
153
182
|
if (!draft.agentId || !draft.task.trim()) {
|
|
154
183
|
setError("Agent and task are required.");
|
|
@@ -366,6 +395,26 @@ export function useOrchestratorData({
|
|
|
366
395
|
await reload();
|
|
367
396
|
};
|
|
368
397
|
|
|
398
|
+
const previewSettings = (id) => {
|
|
399
|
+
setSettingsAction((prev) => reduceSettingsAction(prev, { type: "preview", id }));
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const promptConfirmSettings = () => {
|
|
403
|
+
setSettingsAction((prev) => reduceSettingsAction(prev, { type: "confirm-prompt" }));
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const confirmSettings = () => {
|
|
407
|
+
setSettingsAction((prev) => reduceSettingsAction(prev, { type: "confirm" }));
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const cancelSettings = () => {
|
|
411
|
+
setSettingsAction((prev) => reduceSettingsAction(prev, { type: "cancel" }));
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const resetSettings = () => {
|
|
415
|
+
setSettingsAction(() => createSettingsActionState());
|
|
416
|
+
};
|
|
417
|
+
|
|
369
418
|
return {
|
|
370
419
|
loading,
|
|
371
420
|
busy,
|
|
@@ -381,6 +430,7 @@ export function useOrchestratorData({
|
|
|
381
430
|
reviews,
|
|
382
431
|
selectedReview,
|
|
383
432
|
setSelectedReview,
|
|
433
|
+
alerts,
|
|
384
434
|
statusMessage,
|
|
385
435
|
launchAgentIndex,
|
|
386
436
|
setLaunchAgentIndex,
|
|
@@ -394,11 +444,14 @@ export function useOrchestratorData({
|
|
|
394
444
|
scanOptions: CONTROL_PLANE_AUTO_SCAN,
|
|
395
445
|
changesAction,
|
|
396
446
|
recoveryAction,
|
|
447
|
+
settingsAction,
|
|
448
|
+
curatedIntegrations: listCuratedIntegrations(),
|
|
397
449
|
reload,
|
|
398
450
|
resetLaunchWizard,
|
|
399
451
|
openRunDetail,
|
|
400
452
|
loadReviews,
|
|
401
453
|
openReviewDetail,
|
|
454
|
+
handleAlertTransition,
|
|
402
455
|
handleLaunch,
|
|
403
456
|
handleCancelRun,
|
|
404
457
|
previewChanges,
|
|
@@ -409,6 +462,11 @@ export function useOrchestratorData({
|
|
|
409
462
|
cancelRecovery,
|
|
410
463
|
confirmApplyRecovery,
|
|
411
464
|
rescanRecovery,
|
|
465
|
+
previewSettings,
|
|
466
|
+
promptConfirmSettings,
|
|
467
|
+
confirmSettings,
|
|
468
|
+
cancelSettings,
|
|
469
|
+
resetSettings,
|
|
412
470
|
recoverySnapshots: listRecoverySnapshots(snapshot)
|
|
413
471
|
};
|
|
414
472
|
}
|
package/src/global/paths.js
CHANGED
|
@@ -19,6 +19,9 @@ export function harnessHomePaths(homeDir) {
|
|
|
19
19
|
historyPath: join(root, "history.jsonl"),
|
|
20
20
|
runsDir: join(root, "runs"),
|
|
21
21
|
reviewsDir: join(root, "reviews"),
|
|
22
|
+
alertsDir: join(root, "alerts"),
|
|
23
|
+
monitorDir: join(root, "monitor"),
|
|
24
|
+
monitorStatePath: join(root, "monitor", "state.json"),
|
|
22
25
|
coreDir: join(root, "core"),
|
|
23
26
|
backupsDir: join(root, "backups")
|
|
24
27
|
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { harnessHomePaths } from "../../paths.js";
|
|
5
|
+
import { writeAtomicJson } from "../write-atomic-json.js";
|
|
6
|
+
import {
|
|
7
|
+
ALERT_STATES,
|
|
8
|
+
assertSafeAlertId,
|
|
9
|
+
createAlert,
|
|
10
|
+
createAlertFingerprint
|
|
11
|
+
} from "./alert-types.js";
|
|
12
|
+
import { assertAlertSecretFree } from "./alert-validate.js";
|
|
13
|
+
|
|
14
|
+
const TERMINAL_ALERT_STATES = new Set([ALERT_STATES.RESOLVED, ALERT_STATES.DISMISSED]);
|
|
15
|
+
|
|
16
|
+
export class AlertStoreError extends Error {
|
|
17
|
+
constructor(message, { code = "alert_store_error", details = null } = {}) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "AlertStoreError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.details = details;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function alertPaths(homeDir, alertId) {
|
|
26
|
+
assertSafeAlertId(alertId);
|
|
27
|
+
const alertDir = join(harnessHomePaths(homeDir).alertsDir, alertId);
|
|
28
|
+
return { alertDir, alertPath: join(alertDir, "alert.json") };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function openIndexPath(homeDir, fingerprint) {
|
|
32
|
+
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) {
|
|
33
|
+
throw new AlertStoreError(`Invalid alert fingerprint "${fingerprint}".`, {
|
|
34
|
+
code: "invalid_fingerprint"
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return join(harnessHomePaths(homeDir).alertsDir, "open", fingerprint);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openDirPath(homeDir) {
|
|
41
|
+
return join(harnessHomePaths(homeDir).alertsDir, "open");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readOpenAlert(indexPath) {
|
|
45
|
+
const expectedFingerprint = basename(indexPath);
|
|
46
|
+
const alert = assertAlertSecretFree(JSON.parse(await readFile(indexPath, "utf8")));
|
|
47
|
+
if (alert.fingerprint !== expectedFingerprint || alert.state !== ALERT_STATES.OPEN) {
|
|
48
|
+
throw new AlertStoreError(`Corrupt open alert claim "${expectedFingerprint}".`, {
|
|
49
|
+
code: "corrupt_alert",
|
|
50
|
+
details: {
|
|
51
|
+
fingerprint: expectedFingerprint,
|
|
52
|
+
payloadFingerprint: alert.fingerprint,
|
|
53
|
+
state: alert.state
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return alert;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readHistoryAlert(homeDir, alertId) {
|
|
61
|
+
const alert = assertAlertSecretFree(
|
|
62
|
+
JSON.parse(await readFile(alertPaths(homeDir, alertId).alertPath, "utf8"))
|
|
63
|
+
);
|
|
64
|
+
if (alert.alertId !== alertId || !TERMINAL_ALERT_STATES.has(alert.state)) {
|
|
65
|
+
throw new AlertStoreError(`Corrupt history alert "${alertId}".`, {
|
|
66
|
+
code: "corrupt_alert",
|
|
67
|
+
details: {
|
|
68
|
+
alertId,
|
|
69
|
+
payloadAlertId: alert.alertId,
|
|
70
|
+
state: alert.state
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return alert;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function wrapCorruptAlert(label, details, error) {
|
|
78
|
+
if (error instanceof AlertStoreError) throw error;
|
|
79
|
+
throw new AlertStoreError(`Corrupt or unreadable ${label}.`, {
|
|
80
|
+
code: "corrupt_alert",
|
|
81
|
+
details: {
|
|
82
|
+
...details,
|
|
83
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function listOpenAlerts(homeDir) {
|
|
89
|
+
const dir = openDirPath(homeDir);
|
|
90
|
+
if (!existsSync(dir)) return [];
|
|
91
|
+
const alerts = [];
|
|
92
|
+
for (const name of await readdir(dir)) {
|
|
93
|
+
if (!/^[a-f0-9]{64}$/.test(name)) continue;
|
|
94
|
+
try {
|
|
95
|
+
alerts.push(await readOpenAlert(join(dir, name)));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
wrapCorruptAlert(`open alert "${name}"`, { fingerprint: name }, error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return alerts;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function findOpenAlert(homeDir, alertId) {
|
|
104
|
+
assertSafeAlertId(alertId);
|
|
105
|
+
for (const alert of await listOpenAlerts(homeDir)) {
|
|
106
|
+
if (alert.alertId === alertId) return alert;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function loadAlert(alertId, { homeDir } = {}) {
|
|
112
|
+
const open = await findOpenAlert(homeDir, alertId);
|
|
113
|
+
if (open) return open;
|
|
114
|
+
const { alertPath } = alertPaths(homeDir, alertId);
|
|
115
|
+
if (!existsSync(alertPath)) throw new Error(`Alert not found: ${alertId}`);
|
|
116
|
+
return readHistoryAlert(homeDir, alertId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function writeHistoryAlert(alert, { homeDir } = {}) {
|
|
120
|
+
const sanitized = assertAlertSecretFree(alert);
|
|
121
|
+
const { alertDir, alertPath } = alertPaths(homeDir, sanitized.alertId);
|
|
122
|
+
await mkdir(alertDir, { recursive: true });
|
|
123
|
+
await writeAtomicJson(alertPath, sanitized);
|
|
124
|
+
return sanitized;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Persist an open alert. `open/<fingerprint>` is the authoritative open record
|
|
129
|
+
* (exclusive create); no secondary mutex.
|
|
130
|
+
*/
|
|
131
|
+
export async function saveAlert(input, { homeDir } = {}) {
|
|
132
|
+
const draft = input?.version === 1 ? { ...input } : createAlert(input);
|
|
133
|
+
draft.fingerprint = createAlertFingerprint(draft);
|
|
134
|
+
const candidate = assertAlertSecretFree(draft);
|
|
135
|
+
if (candidate.state !== ALERT_STATES.OPEN) {
|
|
136
|
+
throw new AlertStoreError("Only open alerts can be saved to the open index.", {
|
|
137
|
+
code: "invalid_alert_state",
|
|
138
|
+
details: { state: candidate.state, fingerprint: candidate.fingerprint }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const indexPath = openIndexPath(homeDir, candidate.fingerprint);
|
|
142
|
+
await mkdir(openDirPath(homeDir), { recursive: true });
|
|
143
|
+
|
|
144
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
145
|
+
try {
|
|
146
|
+
await writeAtomicJson(indexPath, candidate, { createExclusive: true });
|
|
147
|
+
return { alert: candidate, deduped: false };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.code !== "EEXIST") throw error;
|
|
150
|
+
try {
|
|
151
|
+
return { alert: await readOpenAlert(indexPath), deduped: true };
|
|
152
|
+
} catch (readError) {
|
|
153
|
+
if (readError?.code !== "ENOENT") throw readError;
|
|
154
|
+
// Claim removed between EEXIST and read (resolve/dismiss race) — retry create.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new AlertStoreError("Unable to claim open alert fingerprint.", {
|
|
160
|
+
code: "claim_failed",
|
|
161
|
+
details: { fingerprint: candidate.fingerprint }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* List alerts. Open records come from open/<fingerprint>;
|
|
167
|
+
* terminal history comes from alt-<id>/alert.json.
|
|
168
|
+
* Corrupt records fail closed.
|
|
169
|
+
*/
|
|
170
|
+
export async function listAlerts({ homeDir, state = null, limit = null } = {}) {
|
|
171
|
+
const dir = harnessHomePaths(homeDir).alertsDir;
|
|
172
|
+
if (!existsSync(dir)) return [];
|
|
173
|
+
|
|
174
|
+
const open = await listOpenAlerts(homeDir);
|
|
175
|
+
const openIds = new Set(open.map((alert) => alert.alertId));
|
|
176
|
+
const alerts = [...open];
|
|
177
|
+
|
|
178
|
+
for (const alertId of (await readdir(dir)).filter((n) => /^alt-[a-f0-9]{16,32}$/.test(n))) {
|
|
179
|
+
if (openIds.has(alertId)) continue;
|
|
180
|
+
try {
|
|
181
|
+
alerts.push(await readHistoryAlert(homeDir, alertId));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
wrapCorruptAlert(`alert "${alertId}"`, { alertId }, error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
alerts.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))
|
|
188
|
+
|| String(a.alertId).localeCompare(String(b.alertId)));
|
|
189
|
+
const filtered = state ? alerts.filter((a) => a.state === state) : alerts;
|
|
190
|
+
return Number.isInteger(limit) && limit >= 0 ? filtered.slice(0, limit) : filtered;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function transitionAlert(alertId, nextState, { homeDir } = {}) {
|
|
194
|
+
const current = await loadAlert(alertId, { homeDir });
|
|
195
|
+
if (current.state !== ALERT_STATES.OPEN) return current;
|
|
196
|
+
const now = new Date().toISOString();
|
|
197
|
+
const updated = await writeHistoryAlert({
|
|
198
|
+
...current,
|
|
199
|
+
state: nextState,
|
|
200
|
+
updatedAt: now,
|
|
201
|
+
resolvedAt: now
|
|
202
|
+
}, { homeDir });
|
|
203
|
+
const indexPath = openIndexPath(homeDir, current.fingerprint);
|
|
204
|
+
await unlink(indexPath).catch((error) => {
|
|
205
|
+
if (error?.code !== "ENOENT") throw error;
|
|
206
|
+
});
|
|
207
|
+
return updated;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function resolveAlert(alertId, { homeDir } = {}) {
|
|
211
|
+
return transitionAlert(alertId, ALERT_STATES.RESOLVED, { homeDir });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function dismissAlert(alertId, { homeDir } = {}) {
|
|
215
|
+
return transitionAlert(alertId, ALERT_STATES.DISMISSED, { homeDir });
|
|
216
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const ALERT_STATES = Object.freeze({
|
|
4
|
+
OPEN: "open", RESOLVED: "resolved", DISMISSED: "dismissed"
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const ALERT_SEVERITIES = Object.freeze({
|
|
8
|
+
HIGH: "high", MEDIUM: "medium", LOW: "low"
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function createAlertId() {
|
|
12
|
+
return `alt-${randomBytes(12).toString("hex")}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assertSafeAlertId(alertId) {
|
|
16
|
+
if (typeof alertId !== "string" || !/^alt-[a-f0-9]{16,32}$/.test(alertId)) {
|
|
17
|
+
throw new Error(`Invalid alert id "${alertId}".`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Stable dedupe key — kind + source + title only (never payloads). */
|
|
22
|
+
export function createAlertFingerprint({ kind, source = null, title }) {
|
|
23
|
+
return createHash("sha256")
|
|
24
|
+
.update([kind, source ?? "", title].map(String).join("\0"))
|
|
25
|
+
.digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createAlert({
|
|
29
|
+
alertId = createAlertId(),
|
|
30
|
+
kind,
|
|
31
|
+
severity = ALERT_SEVERITIES.MEDIUM,
|
|
32
|
+
title,
|
|
33
|
+
summary = "",
|
|
34
|
+
source = null,
|
|
35
|
+
state = ALERT_STATES.OPEN,
|
|
36
|
+
createdAt = null,
|
|
37
|
+
updatedAt = null,
|
|
38
|
+
resolvedAt = null
|
|
39
|
+
} = {}) {
|
|
40
|
+
assertSafeAlertId(alertId);
|
|
41
|
+
const safeKind = String(kind ?? "").trim();
|
|
42
|
+
const safeTitle = String(title ?? "").trim();
|
|
43
|
+
if (!safeKind || !safeTitle) throw new Error("Alert kind and title are required.");
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
return {
|
|
46
|
+
version: 1,
|
|
47
|
+
alertId,
|
|
48
|
+
kind: safeKind,
|
|
49
|
+
severity,
|
|
50
|
+
title: safeTitle,
|
|
51
|
+
summary: String(summary ?? "").trim(),
|
|
52
|
+
source: source == null ? null : String(source),
|
|
53
|
+
fingerprint: createAlertFingerprint({ kind: safeKind, source, title: safeTitle }),
|
|
54
|
+
state,
|
|
55
|
+
createdAt: createdAt ?? now,
|
|
56
|
+
updatedAt: updatedAt ?? now,
|
|
57
|
+
resolvedAt: resolvedAt ?? null
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ALERT_SEVERITIES,
|
|
3
|
+
ALERT_STATES,
|
|
4
|
+
assertSafeAlertId,
|
|
5
|
+
createAlertFingerprint
|
|
6
|
+
} from "./alert-types.js";
|
|
7
|
+
|
|
8
|
+
export const ALERT_VALIDATION_ERROR_CODES = Object.freeze({
|
|
9
|
+
INVALID_ALERT: "invalid_alert",
|
|
10
|
+
FORBIDDEN_FIELD: "forbidden_field",
|
|
11
|
+
FINGERPRINT_MISMATCH: "fingerprint_mismatch"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const SEVERITY_SET = new Set(Object.values(ALERT_SEVERITIES));
|
|
15
|
+
const STATE_SET = new Set(Object.values(ALERT_STATES));
|
|
16
|
+
const FORBIDDEN_KEYS = new Set([
|
|
17
|
+
"prompt", "diff", "transcript", "raw", "rawOutput", "stdout", "stderr",
|
|
18
|
+
"output", "message", "messages", "content", "secret", "secrets", "token", "apiKey"
|
|
19
|
+
]);
|
|
20
|
+
const ALLOWED = new Set([
|
|
21
|
+
"version", "alertId", "kind", "severity", "title", "summary",
|
|
22
|
+
"source", "fingerprint", "state", "createdAt", "updatedAt", "resolvedAt"
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export class AlertValidationError extends Error {
|
|
26
|
+
constructor(message, { code, details = null } = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "AlertValidationError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.details = details;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertNoForbiddenKeys(value, label) {
|
|
35
|
+
if (!value || typeof value !== "object") return;
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const item of value) assertNoForbiddenKeys(item, label);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const [key, child] of Object.entries(value)) {
|
|
41
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
42
|
+
throw new AlertValidationError(`Forbidden field "${key}" in ${label}.`, {
|
|
43
|
+
code: ALERT_VALIDATION_ERROR_CODES.FORBIDDEN_FIELD,
|
|
44
|
+
details: { key, label }
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
assertNoForbiddenKeys(child, `${label}.${key}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requireString(alert, key, { allowEmpty = false } = {}) {
|
|
52
|
+
const value = alert[key];
|
|
53
|
+
if (typeof value !== "string" || (!allowEmpty && !value.trim())) {
|
|
54
|
+
throw new AlertValidationError(`Invalid alert.${key}: expected string.`, {
|
|
55
|
+
code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT,
|
|
56
|
+
details: { key }
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fail-closed scalar schema + recursive secret scan + derived fingerprint. */
|
|
63
|
+
export function assertAlertSecretFree(alert) {
|
|
64
|
+
if (!alert || typeof alert !== "object" || Array.isArray(alert)) {
|
|
65
|
+
throw new AlertValidationError("Invalid alert: expected object.", {
|
|
66
|
+
code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
assertNoForbiddenKeys(alert, "alert");
|
|
70
|
+
for (const key of Object.keys(alert)) {
|
|
71
|
+
if (!ALLOWED.has(key)) {
|
|
72
|
+
throw new AlertValidationError(`Unknown field "${key}" in alert.`, {
|
|
73
|
+
code: ALERT_VALIDATION_ERROR_CODES.FORBIDDEN_FIELD,
|
|
74
|
+
details: { key }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (typeof alert.version !== "number" || !Number.isFinite(alert.version)) {
|
|
79
|
+
throw new AlertValidationError("Invalid alert.version.", {
|
|
80
|
+
code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
assertSafeAlertId(requireString(alert, "alertId"));
|
|
84
|
+
requireString(alert, "kind");
|
|
85
|
+
requireString(alert, "title");
|
|
86
|
+
requireString(alert, "summary", { allowEmpty: true });
|
|
87
|
+
requireString(alert, "fingerprint");
|
|
88
|
+
requireString(alert, "createdAt");
|
|
89
|
+
requireString(alert, "updatedAt");
|
|
90
|
+
if (!(alert.source === null || typeof alert.source === "string")) {
|
|
91
|
+
throw new AlertValidationError("Invalid alert.source.", {
|
|
92
|
+
code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (!(alert.resolvedAt === null || typeof alert.resolvedAt === "string")) {
|
|
96
|
+
throw new AlertValidationError("Invalid alert.resolvedAt.", {
|
|
97
|
+
code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!SEVERITY_SET.has(alert.severity) || !STATE_SET.has(alert.state)) {
|
|
101
|
+
throw new AlertValidationError("Unknown alert severity or state.", {
|
|
102
|
+
code: ALERT_VALIDATION_ERROR_CODES.INVALID_ALERT
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const expected = createAlertFingerprint({
|
|
106
|
+
kind: alert.kind,
|
|
107
|
+
source: alert.source,
|
|
108
|
+
title: alert.title
|
|
109
|
+
});
|
|
110
|
+
if (alert.fingerprint !== expected) {
|
|
111
|
+
throw new AlertValidationError("Alert fingerprint does not match kind+source+title.", {
|
|
112
|
+
code: ALERT_VALIDATION_ERROR_CODES.FINGERPRINT_MISMATCH,
|
|
113
|
+
details: { expected }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return alert;
|
|
117
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { resolveHomeDir } from "../../paths.js";
|
|
3
|
+
import { printJson } from "../../json-output.js";
|
|
4
|
+
import { commandHeader } from "../../brand/index.js";
|
|
5
|
+
import {
|
|
6
|
+
disableMonitor, enableMonitor, getMonitorStatus, runMonitorTick
|
|
7
|
+
} from "./monitor.js";
|
|
8
|
+
|
|
9
|
+
export async function runGlobalMonitor(options, _manifest, deps = {}) {
|
|
10
|
+
const homeDir = deps.homeDir ?? resolveHomeDir();
|
|
11
|
+
const action = options.monitorAction ?? "status";
|
|
12
|
+
const packageRoot = deps.packageRoot;
|
|
13
|
+
const entry = deps.cliEntry ?? (packageRoot ? join(packageRoot, "bin", "kairo.js") : null);
|
|
14
|
+
try {
|
|
15
|
+
if (action === "enable") {
|
|
16
|
+
await (deps.enableMonitorImpl ?? enableMonitor)(homeDir, {
|
|
17
|
+
cliEntry: entry, nodePath: deps.nodePath ?? process.execPath, platform: deps.platform
|
|
18
|
+
});
|
|
19
|
+
await (deps.runMonitorTickImpl ?? runMonitorTick)(homeDir, {
|
|
20
|
+
packageRoot, workspaceRoot: options.cwd, notifyImpl: deps.notifyImpl
|
|
21
|
+
});
|
|
22
|
+
} else if (action === "disable") {
|
|
23
|
+
await (deps.disableMonitorImpl ?? disableMonitor)(homeDir, { platform: deps.platform });
|
|
24
|
+
} else if (action === "tick") {
|
|
25
|
+
const result = await (deps.runMonitorTickImpl ?? runMonitorTick)(homeDir, {
|
|
26
|
+
packageRoot, workspaceRoot: options.cwd, notifyImpl: deps.notifyImpl
|
|
27
|
+
});
|
|
28
|
+
return done(options, { ok: true, action, raised: result.raised.length, lastTick: result.state.lastTick });
|
|
29
|
+
}
|
|
30
|
+
const status = await (deps.getMonitorStatusImpl ?? getMonitorStatus)(homeDir, {
|
|
31
|
+
platform: deps.platform
|
|
32
|
+
});
|
|
33
|
+
return done(options, { ok: true, action: action === "status" ? "status" : action, ...status });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const message = String(error?.message ?? error);
|
|
36
|
+
if (options.json) printJson({ ok: false, error: message });
|
|
37
|
+
else console.error(message);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return { ok: false, error: message };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function done(options, payload) {
|
|
44
|
+
if (options.json) { printJson(payload); return payload; }
|
|
45
|
+
console.log(commandHeader(`monitor ${payload.action}`));
|
|
46
|
+
if (payload.action === "tick") {
|
|
47
|
+
console.log(`Raised ${payload.raised} alert(s) this tick.`);
|
|
48
|
+
return payload;
|
|
49
|
+
}
|
|
50
|
+
console.log(`Enabled: ${payload.corrupt ? "unavailable" : payload.enabled ? "yes" : "no"} · ${payload.platform}`);
|
|
51
|
+
if (payload.corrupt) console.log("State: corrupt — run monitor disable to repair");
|
|
52
|
+
else {
|
|
53
|
+
const a = payload.autostart;
|
|
54
|
+
console.log(`Autostart: ${a?.loaded ? "loaded" : a?.configured ? "configured (not loaded)" : "off"}`
|
|
55
|
+
+ `${a?.supported === false ? " (unsupported)" : ""}`);
|
|
56
|
+
}
|
|
57
|
+
console.log(`Open alerts: ${payload.openAlerts ?? "unavailable"}`);
|
|
58
|
+
if (payload.lastTickAt) {
|
|
59
|
+
console.log(`Last tick: ${payload.lastTickAt}${payload.lastTick?.complete === false ? " (incomplete)" : ""}`);
|
|
60
|
+
}
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCb);
|
|
9
|
+
export const MONITOR_LABEL = "local.kairo.monitor";
|
|
10
|
+
|
|
11
|
+
export function resolveMonitorPlatform(platform = process.platform) {
|
|
12
|
+
if (platform === "darwin") {
|
|
13
|
+
const agentsDir = join(homedir(), "Library", "LaunchAgents");
|
|
14
|
+
return {
|
|
15
|
+
id: "darwin", supportsAutostart: true, supportsNotify: true,
|
|
16
|
+
agentsDir, plistPath: join(agentsDir, `${MONITOR_LABEL}.plist`)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { id: platform, supportsAutostart: false, supportsNotify: platform === "linux" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gui() { return `gui/${process.getuid?.() ?? 501}`; }
|
|
23
|
+
|
|
24
|
+
function plist({ nodePath, cliEntry, homeDir, intervalSec }) {
|
|
25
|
+
const e = (v) => String(v).replaceAll("&", "&").replaceAll("<", "<")
|
|
26
|
+
.replaceAll(">", ">").replaceAll("\"", """);
|
|
27
|
+
const log = join(homeDir, ".harness", "monitor");
|
|
28
|
+
const n = Math.max(60, Number(intervalSec) || 300);
|
|
29
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
31
|
+
<plist version="1.0"><dict>
|
|
32
|
+
<key>Label</key><string>${MONITOR_LABEL}</string>
|
|
33
|
+
<key>ProgramArguments</key><array><string>${e(nodePath)}</string><string>${e(cliEntry)}</string><string>monitor</string><string>tick</string></array>
|
|
34
|
+
<key>StartInterval</key><integer>${n}</integer><key>RunAtLoad</key><true/>
|
|
35
|
+
<key>EnvironmentVariables</key><dict><key>HARNESS_HOME</key><string>${e(homeDir)}</string></dict>
|
|
36
|
+
<key>StandardOutPath</key><string>${e(join(log, "out.log"))}</string>
|
|
37
|
+
<key>StandardErrorPath</key><string>${e(join(log, "err.log"))}</string>
|
|
38
|
+
</dict></plist>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function notifyNewAlert({
|
|
42
|
+
title, body, platform = resolveMonitorPlatform(), execFileImpl = execFile
|
|
43
|
+
} = {}) {
|
|
44
|
+
const t = String(title ?? "Kairo").slice(0, 80);
|
|
45
|
+
const b = String(body ?? "").slice(0, 180).replace(/[\r\n]+/g, " ");
|
|
46
|
+
try {
|
|
47
|
+
if (platform.id === "darwin") {
|
|
48
|
+
await execFileImpl("osascript", [
|
|
49
|
+
"-e", `display notification ${JSON.stringify(b)} with title ${JSON.stringify(t)}`
|
|
50
|
+
], { shell: false, timeout: 5000 });
|
|
51
|
+
return { sent: true };
|
|
52
|
+
}
|
|
53
|
+
if (platform.id === "linux") {
|
|
54
|
+
await execFileImpl("notify-send", [t, b], { shell: false, timeout: 5000 });
|
|
55
|
+
return { sent: true };
|
|
56
|
+
}
|
|
57
|
+
} catch { /* degrade */ }
|
|
58
|
+
return { sent: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function installAutostart({
|
|
62
|
+
homeDir, platform = resolveMonitorPlatform(), nodePath, cliEntry,
|
|
63
|
+
intervalSec = 300, execFileImpl = execFile
|
|
64
|
+
} = {}) {
|
|
65
|
+
if (!platform.supportsAutostart) {
|
|
66
|
+
return {
|
|
67
|
+
supported: false, configured: false, loaded: false, installed: false,
|
|
68
|
+
detail: `unsupported on ${platform.id}`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
await mkdir(platform.agentsDir, { recursive: true });
|
|
72
|
+
await mkdir(join(homeDir, ".harness", "monitor"), { recursive: true });
|
|
73
|
+
await writeFile(platform.plistPath, plist({ nodePath, cliEntry, homeDir, intervalSec }));
|
|
74
|
+
try {
|
|
75
|
+
await execFileImpl("launchctl", ["bootout", `${gui()}/${MONITOR_LABEL}`], { shell: false }).catch(() => {});
|
|
76
|
+
await execFileImpl("launchctl", ["bootstrap", gui(), platform.plistPath], { shell: false });
|
|
77
|
+
return { supported: true, configured: true, loaded: true, installed: true, detail: "LaunchAgent loaded" };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
supported: true, configured: true, loaded: false, installed: false,
|
|
81
|
+
detail: `plist configured; not loaded (${error?.message ?? error})`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function removeAutostart({
|
|
87
|
+
platform = resolveMonitorPlatform(), execFileImpl = execFile
|
|
88
|
+
} = {}) {
|
|
89
|
+
if (!platform.supportsAutostart) {
|
|
90
|
+
return { supported: false, configured: false, loaded: false, installed: false };
|
|
91
|
+
}
|
|
92
|
+
await execFileImpl("launchctl", ["bootout", `${gui()}/${MONITOR_LABEL}`], { shell: false }).catch(() => {});
|
|
93
|
+
if (platform.plistPath && existsSync(platform.plistPath)) await unlink(platform.plistPath).catch(() => {});
|
|
94
|
+
return { supported: true, configured: false, loaded: false, installed: false };
|
|
95
|
+
}
|