@kal-elsam/kairo-runtime 0.8.0 → 0.10.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 +41 -15
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +6 -6
- package/scripts/ux-prototype-tty.mjs +9 -0
- package/src/cli.js +24 -1
- package/src/global/control-plane-proposals.js +2 -1
- package/src/global/control-plane-snapshot.js +1 -1
- package/src/global/global-doctor.js +2 -0
- package/src/global/ink/cockpit/primitives.js +127 -50
- package/src/global/ink/cockpit-alerts.js +36 -0
- package/src/global/ink/cockpit-changes.js +61 -37
- package/src/global/ink/cockpit-control-center.js +79 -53
- package/src/global/ink/cockpit-controller.js +98 -15
- package/src/global/ink/cockpit-enter.js +1 -0
- package/src/global/ink/cockpit-focus.js +4 -2
- package/src/global/ink/cockpit-models.js +100 -51
- package/src/global/ink/cockpit-palette.js +109 -0
- package/src/global/ink/cockpit-path-label.js +19 -0
- package/src/global/ink/cockpit-recovery.js +84 -18
- 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-usage.js +111 -0
- package/src/global/ink/cockpit-views.js +119 -116
- package/src/global/ink/orchestrator-app.js +169 -46
- package/src/global/ink/orchestrator-state.js +24 -14
- package/src/global/ink/setup-app.js +55 -72
- package/src/global/ink/setup-state.js +16 -0
- package/src/global/ink/theme.js +27 -0
- package/src/global/ink/use-orchestrator-data.js +58 -0
- package/src/global/ink/ux/live-activity.js +194 -0
- package/src/global/ink/ux/live-alerts.js +159 -0
- package/src/global/ink/ux/live-governance.js +195 -0
- package/src/global/ink/ux/live-orchestration.js +188 -0
- package/src/global/ink/ux/live-overview.js +125 -0
- package/src/global/ink/ux/live-settings.js +189 -0
- package/src/global/ink/ux/live-setup.js +160 -0
- package/src/global/ink/ux/live-usage.js +51 -0
- package/src/global/ink/ux/semantic.js +84 -0
- package/src/global/ink/ux/task-flow-app.js +85 -0
- package/src/global/ink/ux/task-flow.js +173 -0
- package/src/global/orchestrator.js +37 -20
- 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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live semantic Usage panel — renderer only; model lives in cockpit-usage.js.
|
|
3
|
+
* ActionList never owns focus.
|
|
4
|
+
*/
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Box, Text } from "ink";
|
|
7
|
+
import { COCKPIT_COLORS } from "../theme.js";
|
|
8
|
+
import { LAYOUT_MODES } from "../layout.js";
|
|
9
|
+
import { adaptUsageModel } from "../cockpit-usage.js";
|
|
10
|
+
import { ActionList, Callout } from "./semantic.js";
|
|
11
|
+
|
|
12
|
+
export function SemanticUsagePanel({
|
|
13
|
+
snapshot = null,
|
|
14
|
+
dashboard = null,
|
|
15
|
+
layoutMode = LAYOUT_MODES.COMPACT,
|
|
16
|
+
colorEnabled = true,
|
|
17
|
+
unicode = true
|
|
18
|
+
}) {
|
|
19
|
+
const model = adaptUsageModel({ snapshot, dashboard, layoutMode });
|
|
20
|
+
const muted = colorEnabled ? COCKPIT_COLORS.muted : undefined;
|
|
21
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
22
|
+
React.createElement(Text, {
|
|
23
|
+
bold: true, color: colorEnabled ? COCKPIT_COLORS.secondary : undefined
|
|
24
|
+
}, model.title),
|
|
25
|
+
React.createElement(Callout, {
|
|
26
|
+
tone: model.callout.tone,
|
|
27
|
+
title: model.callout.title,
|
|
28
|
+
body: model.callout.body || undefined,
|
|
29
|
+
colorEnabled,
|
|
30
|
+
compact: true
|
|
31
|
+
}),
|
|
32
|
+
React.createElement(Text, { bold: true }, "Measured"),
|
|
33
|
+
React.createElement(Text, null, ` ${model.measured}`),
|
|
34
|
+
React.createElement(Text, { bold: true }, "Configured limits"),
|
|
35
|
+
React.createElement(Text, null, ` ${model.configured}`),
|
|
36
|
+
React.createElement(Text, { bold: true }, "Run usage"),
|
|
37
|
+
model.runs.length === 0
|
|
38
|
+
? React.createElement(Text, null, " No auditable run tokenUsage yet.")
|
|
39
|
+
: React.createElement(ActionList, {
|
|
40
|
+
items: model.runs,
|
|
41
|
+
selectedIndex: -1,
|
|
42
|
+
focused: false,
|
|
43
|
+
colorEnabled,
|
|
44
|
+
unicode
|
|
45
|
+
}),
|
|
46
|
+
model.moreLine
|
|
47
|
+
? React.createElement(Text, { color: muted }, model.moreLine)
|
|
48
|
+
: null,
|
|
49
|
+
React.createElement(Text, { color: muted }, model.footnote)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Semantic Ink primitives — structured models, not string-array panels. */
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { COCKPIT_COLORS, statusColor, resolveGlyphs } from "../theme.js";
|
|
5
|
+
|
|
6
|
+
const mute = (on) => (on ? COCKPIT_COLORS.muted : undefined);
|
|
7
|
+
|
|
8
|
+
export function ActionList({ items = [], selectedIndex = 0, focused = true, colorEnabled = true, unicode = true }) {
|
|
9
|
+
const g = resolveGlyphs(unicode);
|
|
10
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
11
|
+
...items.map((item, i) => {
|
|
12
|
+
const sel = i === selectedIndex;
|
|
13
|
+
return React.createElement(Text, {
|
|
14
|
+
key: item.id ?? String(i),
|
|
15
|
+
bold: sel,
|
|
16
|
+
color: sel && focused && colorEnabled ? COCKPIT_COLORS.primary : undefined
|
|
17
|
+
}, `${sel && focused ? g.focus : " "} ${item.label}${item.hint ? ` ${item.hint}` : ""}`);
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Stepper({ steps = [], currentIndex = 0, colorEnabled = true, unicode = true }) {
|
|
23
|
+
// Progress marks: not focus `>` and not failure-looking `x`.
|
|
24
|
+
const done = unicode ? "✓" : "+";
|
|
25
|
+
const current = unicode ? "●" : "*";
|
|
26
|
+
const idle = unicode ? "·" : "-";
|
|
27
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
28
|
+
...steps.map((step, i) => {
|
|
29
|
+
const isDone = i < currentIndex;
|
|
30
|
+
const cur = i === currentIndex;
|
|
31
|
+
const color = isDone
|
|
32
|
+
? (colorEnabled ? COCKPIT_COLORS.success : undefined)
|
|
33
|
+
: cur ? (colorEnabled ? COCKPIT_COLORS.primary : undefined) : mute(colorEnabled);
|
|
34
|
+
return React.createElement(Text, {
|
|
35
|
+
key: step.id ?? String(i), bold: cur, color
|
|
36
|
+
}, `${isDone ? done : cur ? current : idle} ${i + 1}. ${step.label}`);
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Callout({ tone = "info", title, body, colorEnabled = true, compact = false }) {
|
|
42
|
+
const kind = tone === "danger" ? "danger" : tone === "warn" ? "warn" : "ready";
|
|
43
|
+
return React.createElement(Box, {
|
|
44
|
+
flexDirection: "column",
|
|
45
|
+
marginY: compact ? 0 : 1
|
|
46
|
+
},
|
|
47
|
+
React.createElement(Text, { bold: true, color: statusColor(kind, { colorEnabled }) }, title),
|
|
48
|
+
body ? React.createElement(Text, null, body) : null
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Confirm({ summary, primaryLabel = "Confirm", focused = true, colorEnabled = true, mark = " " }) {
|
|
53
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
54
|
+
summary ? React.createElement(Text, null, summary) : null,
|
|
55
|
+
React.createElement(Text, {
|
|
56
|
+
bold: focused,
|
|
57
|
+
color: focused && colorEnabled ? COCKPIT_COLORS.primary : undefined
|
|
58
|
+
}, `${mark} ${primaryLabel}`)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Receipt({ title = "Receipt", lines = [], colorEnabled = true }) {
|
|
63
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
64
|
+
React.createElement(Text, { bold: true, color: colorEnabled ? COCKPIT_COLORS.success : undefined }, title),
|
|
65
|
+
...lines.map((line, i) => React.createElement(Text, { key: `r${i}`, color: mute(colorEnabled) }, line))
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function Details({ open = false, summary = "Details", lines = [], colorEnabled = true, focused = false, mark = " " }) {
|
|
70
|
+
const color = focused && colorEnabled ? COCKPIT_COLORS.primary : mute(colorEnabled);
|
|
71
|
+
if (!open) return React.createElement(Text, { bold: focused, color }, `${mark} ${summary} · Space`);
|
|
72
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
73
|
+
React.createElement(Text, { bold: true, color }, `${mark} ${summary}`),
|
|
74
|
+
...lines.map((line, i) => React.createElement(Text, { key: `d${i}`, color: mute(colorEnabled) }, line))
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function KeyBar({ hints = [], colorEnabled = true, columns = 80 }) {
|
|
79
|
+
const width = Math.max(24, Math.min(Number(columns) || 80, 120));
|
|
80
|
+
return React.createElement(Box, { width },
|
|
81
|
+
React.createElement(Text, { color: mute(colorEnabled) },
|
|
82
|
+
hints.map((h) => `${h.keys} ${h.label}`).join(" · "))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React, { useEffect, useReducer } from "react";
|
|
2
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
3
|
+
import { COCKPIT_COLORS, resolveGlyphs } from "../theme.js";
|
|
4
|
+
import { resolveTerminalCapabilities } from "../terminal-capabilities.js";
|
|
5
|
+
import { ActionList, Callout, Confirm, Details, KeyBar, Receipt, Stepper } from "./semantic.js";
|
|
6
|
+
import {
|
|
7
|
+
FOCUS, SCREENS, createTaskFlowState, keyHintsFor, modelForState, reduceTaskFlow, resolvePrimaryPresentation
|
|
8
|
+
} from "./task-flow.js";
|
|
9
|
+
|
|
10
|
+
export function TaskFlowApp({ columns: cols, rows: rowCount, onExit } = {}) {
|
|
11
|
+
const { exit } = useApp();
|
|
12
|
+
const { stdout } = useStdout();
|
|
13
|
+
const [state, dispatch] = useReducer(
|
|
14
|
+
reduceTaskFlow,
|
|
15
|
+
createTaskFlowState({ columns: cols ?? stdout?.columns ?? 80, rows: rowCount ?? stdout?.rows ?? 24 })
|
|
16
|
+
);
|
|
17
|
+
const caps = resolveTerminalCapabilities({
|
|
18
|
+
columns: state.columns, rows: state.rows, env: process.env, isTTY: true
|
|
19
|
+
});
|
|
20
|
+
const model = modelForState(state);
|
|
21
|
+
const primary = resolvePrimaryPresentation(model, state, caps.unicode);
|
|
22
|
+
const detailsMark = state.focus === FOCUS.DETAILS ? resolveGlyphs(caps.unicode).focus : " ";
|
|
23
|
+
const accent = caps.color ? COCKPIT_COLORS.primary : undefined;
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!stdout) return undefined;
|
|
27
|
+
const onResize = () => dispatch({ type: "resize", columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
|
|
28
|
+
stdout.on("resize", onResize);
|
|
29
|
+
return () => stdout.off("resize", onResize);
|
|
30
|
+
}, [stdout]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!state.exited) return;
|
|
34
|
+
onExit?.({ reason: "escape" });
|
|
35
|
+
exit();
|
|
36
|
+
}, [state.exited, exit, onExit]);
|
|
37
|
+
|
|
38
|
+
useInput((input, key) => {
|
|
39
|
+
if (key.upArrow) dispatch({ type: "up" });
|
|
40
|
+
else if (key.downArrow) dispatch({ type: "down" });
|
|
41
|
+
else if (key.return) dispatch({ type: "enter" });
|
|
42
|
+
else if (key.escape) dispatch({ type: "escape" });
|
|
43
|
+
else if (input === " ") dispatch({ type: "space" });
|
|
44
|
+
else if (input === "/") dispatch({ type: "slash" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return React.createElement(Box, { flexDirection: "column", width: state.columns },
|
|
48
|
+
React.createElement(Text, { bold: true, color: accent }, `Kairo · ${model.title} · ${state.layout}`),
|
|
49
|
+
React.createElement(Callout, { ...model.callout, colorEnabled: caps.color }),
|
|
50
|
+
state.screen === SCREENS.SETUP
|
|
51
|
+
? React.createElement(Stepper, {
|
|
52
|
+
steps: model.steps, currentIndex: model.stepIndex, colorEnabled: caps.color, unicode: caps.unicode
|
|
53
|
+
})
|
|
54
|
+
: null,
|
|
55
|
+
primary.mode === "confirm"
|
|
56
|
+
? React.createElement(Confirm, {
|
|
57
|
+
summary: primary.summary, primaryLabel: primary.label,
|
|
58
|
+
focused: state.focus === FOCUS.PRIMARY, colorEnabled: caps.color, mark: primary.mark
|
|
59
|
+
})
|
|
60
|
+
: React.createElement(Box, { flexDirection: "column" },
|
|
61
|
+
React.createElement(Text, {
|
|
62
|
+
bold: state.focus === FOCUS.PRIMARY,
|
|
63
|
+
color: state.focus === FOCUS.PRIMARY ? accent : undefined
|
|
64
|
+
}, `${primary.mark} ${primary.label}`),
|
|
65
|
+
primary.detail
|
|
66
|
+
? React.createElement(Text, { color: caps.color ? COCKPIT_COLORS.muted : undefined }, primary.detail)
|
|
67
|
+
: null
|
|
68
|
+
),
|
|
69
|
+
model.receipt ? React.createElement(Receipt, { ...model.receipt, colorEnabled: caps.color }) : null,
|
|
70
|
+
(model.secondary || model.metrics)
|
|
71
|
+
? React.createElement(ActionList, {
|
|
72
|
+
items: model.secondary ?? model.metrics,
|
|
73
|
+
selectedIndex: state.listIndex,
|
|
74
|
+
focused: state.focus === FOCUS.LIST,
|
|
75
|
+
colorEnabled: caps.color,
|
|
76
|
+
unicode: caps.unicode
|
|
77
|
+
})
|
|
78
|
+
: null,
|
|
79
|
+
React.createElement(Details, {
|
|
80
|
+
open: state.detailsOpen, summary: "Details", lines: model.details,
|
|
81
|
+
colorEnabled: caps.color, focused: state.focus === FOCUS.DETAILS, mark: detailsMark
|
|
82
|
+
}),
|
|
83
|
+
React.createElement(KeyBar, { hints: keyHintsFor(state), colorEnabled: caps.color, columns: state.columns })
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Pure task-flow prototype: Home → Setup → Overview. Mock only — no writes. */
|
|
2
|
+
|
|
3
|
+
export const SCREENS = { HOME: "home", SETUP: "setup", OVERVIEW: "overview" };
|
|
4
|
+
export const FOCUS = { PRIMARY: "primary", LIST: "list", DETAILS: "details" };
|
|
5
|
+
export const SETUP_STEPS = [
|
|
6
|
+
{ id: "detect", label: "Detect agents" },
|
|
7
|
+
{ id: "select", label: "Select governance" },
|
|
8
|
+
{ id: "review", label: "Review changes" },
|
|
9
|
+
{ id: "confirm", label: "Confirm" },
|
|
10
|
+
{ id: "receipt", label: "Receipt" }
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function resolveLayout(c = 80, r = 24) {
|
|
14
|
+
if (c >= 120 && r >= 40) return "wide";
|
|
15
|
+
if (c < 80 || r < 24) return "minimal";
|
|
16
|
+
return "compact";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createTaskFlowState(init = {}) {
|
|
20
|
+
const columns = init.columns ?? 80;
|
|
21
|
+
const rows = init.rows ?? 24;
|
|
22
|
+
return {
|
|
23
|
+
screen: init.screen ?? SCREENS.HOME,
|
|
24
|
+
setupStep: init.setupStep ?? 0,
|
|
25
|
+
listIndex: init.listIndex ?? 0,
|
|
26
|
+
focus: init.focus ?? FOCUS.PRIMARY,
|
|
27
|
+
detailsOpen: Boolean(init.detailsOpen),
|
|
28
|
+
columns,
|
|
29
|
+
rows,
|
|
30
|
+
exited: Boolean(init.exited),
|
|
31
|
+
layout: resolveLayout(columns, rows)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildHomeModel() {
|
|
36
|
+
return {
|
|
37
|
+
title: "Home",
|
|
38
|
+
callout: { tone: "warn", title: "Needs attention", body: "2 open alerts · monitor idle · 1 active run" },
|
|
39
|
+
primary: { id: "start-setup", label: "Start setup · Cursor drift", detail: "Opens guided setup. Stale hooks detected." },
|
|
40
|
+
secondary: [
|
|
41
|
+
{ id: "overview", label: "Open overview" },
|
|
42
|
+
{ id: "details", label: "Show details", hint: "paths · ids" }
|
|
43
|
+
],
|
|
44
|
+
details: ["project: agentic-harness", "path hidden until Details"]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildSetupModel(stepIndex = 0) {
|
|
49
|
+
const step = SETUP_STEPS[stepIndex] ?? SETUP_STEPS[0];
|
|
50
|
+
const isConfirm = step.id === "confirm";
|
|
51
|
+
const isReceipt = step.id === "receipt";
|
|
52
|
+
return {
|
|
53
|
+
title: "Setup",
|
|
54
|
+
steps: SETUP_STEPS,
|
|
55
|
+
stepIndex,
|
|
56
|
+
callout: {
|
|
57
|
+
tone: "info",
|
|
58
|
+
title: step.label,
|
|
59
|
+
body: isReceipt ? "Prototype receipt — no files written." : "Enter advances · Esc leaves setup."
|
|
60
|
+
},
|
|
61
|
+
primary: { label: isReceipt ? "Open overview" : isConfirm ? "Confirm (no write)" : "Continue" },
|
|
62
|
+
confirm: isConfirm ? { summary: "Apply governance preview to Cursor hooks.", primaryLabel: "Confirm (no write)" } : null,
|
|
63
|
+
receipt: isReceipt ? { title: "Receipt", lines: ["status: simulated", "wrote: none", "planId: proto-1"] } : null,
|
|
64
|
+
details: [`step: ${step.id}`, "write: disabled"]
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildOverviewModel() {
|
|
69
|
+
return {
|
|
70
|
+
title: "Overview",
|
|
71
|
+
callout: { tone: "warn", title: "Attention first", body: "2 items need a decision before secondary metrics." },
|
|
72
|
+
primary: { label: "Back to home priority" },
|
|
73
|
+
metrics: [
|
|
74
|
+
{ id: "agents", label: "Protected agents · Cursor · Codex · Pi" },
|
|
75
|
+
{ id: "monitor", label: "Monitor · Disabled" },
|
|
76
|
+
{ id: "runs", label: "Active runs · 1" },
|
|
77
|
+
{ id: "usage", label: "Usage · Pi evidence only" }
|
|
78
|
+
],
|
|
79
|
+
details: ["alert: alt-1", "run: run-1"]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function modelForState(state) {
|
|
84
|
+
if (state.screen === SCREENS.SETUP) return buildSetupModel(state.setupStep);
|
|
85
|
+
if (state.screen === SCREENS.OVERVIEW) return buildOverviewModel();
|
|
86
|
+
return buildHomeModel();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Focus mark only when PRIMARY — list/details own their own markers. */
|
|
90
|
+
export function focusMarkFor(state, unicode = true) {
|
|
91
|
+
return state.focus === FOCUS.PRIMARY ? (unicode ? "›" : ">") : " ";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Exactly one primary surface; confirm steps own the action (no duplicate line). */
|
|
95
|
+
export function resolvePrimaryPresentation(model, state, unicode = true) {
|
|
96
|
+
const mark = focusMarkFor(state, unicode);
|
|
97
|
+
if (model.confirm) {
|
|
98
|
+
return { mode: "confirm", summary: model.confirm.summary, label: model.confirm.primaryLabel, mark };
|
|
99
|
+
}
|
|
100
|
+
return { mode: "primary", label: model.primary.label, detail: model.primary.detail ?? null, mark };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function keyHintsFor(state) {
|
|
104
|
+
const hints = [
|
|
105
|
+
{ keys: "↑↓", label: "Move" },
|
|
106
|
+
{ keys: "Enter", label: "Primary" },
|
|
107
|
+
{ keys: "Space", label: "Details" },
|
|
108
|
+
{ keys: "/", label: "Home" },
|
|
109
|
+
{ keys: "Esc", label: state.screen === SCREENS.SETUP ? "Home" : "Exit" }
|
|
110
|
+
];
|
|
111
|
+
return state.layout === "minimal" ? hints.slice(0, 3) : hints;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function reduceTaskFlow(state, event) {
|
|
115
|
+
switch (event.type) {
|
|
116
|
+
case "resize": {
|
|
117
|
+
const columns = event.columns ?? state.columns;
|
|
118
|
+
const rows = event.rows ?? state.rows;
|
|
119
|
+
return { ...state, columns, rows, layout: resolveLayout(columns, rows) };
|
|
120
|
+
}
|
|
121
|
+
case "up": return moveList(state, -1);
|
|
122
|
+
case "down": return moveList(state, 1);
|
|
123
|
+
case "space": return { ...state, detailsOpen: !state.detailsOpen, focus: FOCUS.DETAILS };
|
|
124
|
+
case "slash":
|
|
125
|
+
return { ...state, screen: SCREENS.HOME, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
|
|
126
|
+
case "escape":
|
|
127
|
+
if (state.detailsOpen) return { ...state, detailsOpen: false, focus: FOCUS.PRIMARY };
|
|
128
|
+
if (state.screen === SCREENS.SETUP) {
|
|
129
|
+
return { ...state, screen: SCREENS.HOME, focus: FOCUS.PRIMARY, listIndex: 0 };
|
|
130
|
+
}
|
|
131
|
+
return { ...state, exited: true };
|
|
132
|
+
case "enter": return activate(state);
|
|
133
|
+
default: return state;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function listLength(state) {
|
|
138
|
+
if (state.screen === SCREENS.HOME) return 2;
|
|
139
|
+
if (state.screen === SCREENS.OVERVIEW) return 4;
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function moveList(state, delta) {
|
|
144
|
+
const len = listLength(state);
|
|
145
|
+
if (len <= 0) return { ...state, focus: FOCUS.PRIMARY };
|
|
146
|
+
if (state.focus !== FOCUS.LIST) {
|
|
147
|
+
return { ...state, listIndex: delta > 0 ? 0 : len - 1, focus: FOCUS.LIST };
|
|
148
|
+
}
|
|
149
|
+
return { ...state, listIndex: (state.listIndex + delta + len) % len, focus: FOCUS.LIST };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function activate(state) {
|
|
153
|
+
if (state.screen === SCREENS.HOME) {
|
|
154
|
+
if (state.focus === FOCUS.LIST) {
|
|
155
|
+
const id = buildHomeModel().secondary[state.listIndex]?.id;
|
|
156
|
+
if (id === "overview") {
|
|
157
|
+
return { ...state, screen: SCREENS.OVERVIEW, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
|
|
158
|
+
}
|
|
159
|
+
if (id === "details") return { ...state, detailsOpen: !state.detailsOpen, focus: FOCUS.DETAILS };
|
|
160
|
+
}
|
|
161
|
+
return { ...state, screen: SCREENS.SETUP, setupStep: 0, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
|
|
162
|
+
}
|
|
163
|
+
if (state.screen === SCREENS.SETUP) {
|
|
164
|
+
if (state.setupStep >= SETUP_STEPS.length - 1) {
|
|
165
|
+
return { ...state, screen: SCREENS.OVERVIEW, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
|
|
166
|
+
}
|
|
167
|
+
return { ...state, setupStep: state.setupStep + 1, focus: FOCUS.PRIMARY, detailsOpen: false };
|
|
168
|
+
}
|
|
169
|
+
if (state.screen === SCREENS.OVERVIEW) {
|
|
170
|
+
return { ...state, screen: SCREENS.HOME, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
|
|
171
|
+
}
|
|
172
|
+
return state;
|
|
173
|
+
}
|
|
@@ -85,27 +85,44 @@ export async function runOrchestratorShell({
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
88
|
+
for (;;) {
|
|
89
|
+
const outcome = await runOrchestratorInkImpl({
|
|
90
|
+
homeDir,
|
|
91
|
+
workspaceRoot,
|
|
92
|
+
packageRoot,
|
|
93
|
+
packageName: packageManifest.name,
|
|
94
|
+
cliVersion: packageManifest.version,
|
|
95
|
+
hasGlobalState: hasConfiguredGlobalState(homeDir),
|
|
96
|
+
fullscreenSession: session
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (outcome.error) {
|
|
100
|
+
throw outcome.error;
|
|
101
|
+
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
if (outcome?.action === "setup") {
|
|
104
|
+
const mid = await runHarnessSetupImpl({
|
|
105
|
+
packageRoot,
|
|
106
|
+
packageName: packageManifest.name,
|
|
107
|
+
cliVersion: packageManifest.version,
|
|
108
|
+
homeDir,
|
|
109
|
+
workspaceRoot,
|
|
110
|
+
onboarding: false,
|
|
111
|
+
interactive: true,
|
|
112
|
+
fullscreenSession: session
|
|
113
|
+
});
|
|
114
|
+
if (!mid?.cancelled) setupOutcome = mid;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
cancelled: Boolean(outcome.cancelled),
|
|
120
|
+
wrote: Boolean(setupOutcome && !setupOutcome.cancelled),
|
|
121
|
+
action: outcome.action ?? null,
|
|
122
|
+
initialMode,
|
|
123
|
+
setup: setupOutcome
|
|
124
|
+
};
|
|
125
|
+
}
|
|
109
126
|
} finally {
|
|
110
127
|
if (ownsSession) {
|
|
111
128
|
session.leave();
|
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
|
+
}
|